studio: add settings-managed LAN access by mahiatlinux · Pull Request #8951 · unslothai/unsloth · GitHub
Skip to content

studio: add settings-managed LAN access - #8951

Merged
Etherll merged 17 commits into
unslothai:mainfrom
mahiatlinux:feat/studio-lan-access
Aug 18, 2026
Merged

studio: add settings-managed LAN access#8951
Etherll merged 17 commits into
unslothai:mainfrom
mahiatlinux:feat/studio-lan-access

Conversation

@mahiatlinux

Copy link
Copy Markdown
Collaborator

Studio binds 127.0.0.1, so reaching it from a phone on the same Wi-Fi meant relaunching with -H 0.0.0.0, and the desktop app cannot do that at all. Requested in #8934 and #8898.

Settings > API keys > LAN access turns network access on and off while Studio is running. It adds a second uvicorn listener over the already-running app, bound to each detected non-loopback IPv4 at the same port, on the primary server's event loop with lifespan="off". The loopback socket is untouched, so the desktop app keeps working the whole time. Each http://<address>:<port> is shown with a copy button and a QR code, and Start automatically brings it up at every launch.

LAN access in Settings > API keys

Started, showing the bound address

Signed in from a phone on the same Wi-Fi

How it works

studio/backend/lan_access.py owns the listener. detect_lan_addresses() takes the default-route address first (a UDP connect that sends nothing) then whatever the host resolves to, dropping loopback, link-local and multicast. Each address is bound explicitly rather than the wildcard, which would collide with the loopback socket already holding the port. start_lan_listener schedules Server.serve(sockets=...) onto the primary loop with asyncio.run_coroutine_threadsafe.

stop_lan_listener waits on the sockets, not on serve(). uvicorn closes the sockets passed to it at the top of shutdown() and only then drains in-flight responses, so waiting on the serve task made a Stop pressed from a LAN device wait out its own response: measured 10.01s before, 0.09s after. It returns whether the port is confirmed released, and a stop that cannot confirm keeps the host marked reachable rather than reopening the loopback-only trust gates.

studio/backend/utils/lan_access_settings.py holds the launch policy and the persisted lan_access_auto_start preference. Start is blocked on Colab, on a --secure launch (run.py forces the loopback bind there so the raw port is never exposed), on a launch that already binds the network, and until the seeded admin password has been changed. An address that is globally routable, which is how a VPS carries its public IPv4, is still bound but reported as public_urls so the section says so instead of calling it a local network.

main.py's _is_remote_frontend_request opens the desktop SPA gate for requests arriving on a LAN listener socket, identified by scope["server"] rather than any client header, so a desktop --api-only backend serves its packaged web UI over LAN without changing the local api-only surface. utils/host_policy.py tracks the LAN listener alongside the Cloudflare tunnel, so turning it on suspends the loopback stdio-MCP default. The startup banner reports the bound addresses when a persisted auto-start has already brought the listener up, instead of printing "Reachable on this machine only".

Checks

  • pytest studio/backend/tests/ — 52 new tests in test_lan_access_settings.py, 244 passing across the affected suites (test_startup_banner_loopback, test_secure_tunnel_gate, test_frontend_resolution, test_remote_access_settings, test_mcp_stdio_pr5863, test_middleware)
  • npm test — 2717 passing, including tests/lan-access-state.test.ts
  • npm run typecheck, npm run build, npm run i18n:check:strict, ruff check, biome check — clean
  • End to end against a real Studio: Start binds and the address answers, a second browser at phone size loads the UI over it and signs in, Stop leaves the address refusing connections while loopback keeps serving, and auto-start brings it back on restart. Repeated for a desktop-shaped --api-only launch, where loopback / stays 404 and the LAN address serves the SPA.

Studio binds 127.0.0.1, so reaching it from a phone on the same Wi-Fi meant
relaunching with -H 0.0.0.0, which the desktop app cannot do at all. Settings >
API keys > LAN access now adds a second uvicorn listener over the running app,
bound to each detected non-loopback IPv4 at the same port, on the primary
server's event loop with lifespan="off". The loopback socket is untouched, so
the desktop app keeps working while it is on.

lan_access.py owns the listener: address detection, binding, and a teardown that
waits on the sockets rather than on serve(), since uvicorn closes the passed
sockets at the top of shutdown and only then drains in-flight responses. Waiting
on the serve task made a Stop pressed from a LAN device wait out its own
response for the full timeout.

utils/lan_access_settings.py holds the launch policy and the persisted
lan_access_auto_start preference. Start is blocked on Colab, on a --secure
launch (which forces the loopback bind so the raw port is never exposed), on a
launch that already binds the network, and until the seeded admin password has
been changed.

main.py's desktop SPA gate now opens for requests arriving on a LAN listener
socket, identified by scope["server"] rather than any client header, so a
desktop api-only backend serves its packaged web UI over LAN without changing
the local api-only surface. host_policy tracks the LAN listener alongside the
Cloudflare tunnel, so turning it on suspends the loopback stdio-MCP default.
The startup banner reports the bound addresses when a persisted auto-start has
already brought the listener up, instead of claiming loopback only.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d78f843372

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread studio/backend/lan_access.py Outdated
Comment thread studio/backend/utils/lan_access_settings.py Outdated
A stop that timed out dropped every reference to the server and sockets before
returning False, so the wait could never be retried and a second stop found
_server None, reported success, and cleared both the stop_timed_out error and
the beyond-loopback trust flag while the port could still be accepting. The
references are now retained on that path, so the status keeps offering Stop and
a retry waits on the same sockets.

start_lan_access also marks the LAN connector active before the listener can
accept, rolling back if startup fails. A request served between the socket
accepting and the flag being set would otherwise read the loopback-only stdio
MCP default in core/inference/mcp_client.py.
@mahiatlinux

Copy link
Copy Markdown
Collaborator Author

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9b39202882

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread studio/backend/utils/lan_access_settings.py Outdated
start_lan_access and stop_lan_access are sync routes, so FastAPI runs them in
separate worker threads. Publishing the beyond-loopback flag from those callers
left it outside lan_access._lock: a stop that observed no server could set the
flag false between a concurrent start setting it true and that start acquiring
the lock, leaving a live LAN listener while remote_connector_active() read
false and core/inference/mcp_client.py still allowed the loopback-default stdio
MCP transport.

The flag now changes only where the listener state does, under the same lock:
start_lan_listener sets it before the socket can accept and rolls it back if
startup fails, and _release_listener_state clears it with the references.
@mahiatlinux

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d98135785a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread studio/backend/lan_access.py Outdated
Comment thread studio/backend/lan_access.py
…h loop shutdown

detect_lan_addresses only had the route to 8.8.8.8 and a hostname lookup. The
probe yields one route-selected source address and hostname resolution is not an
enumeration, so a multihomed host lost its second adapter and an isolated LAN
with no default route reported no_lan_address while having a usable address, the
Linux name-to-127.0.1.1 mapping making it worse. It now enumerates IPv4
addresses on every interface that is up via psutil, keeping the default-route
address first so it stays the shown URL, and falls back to the hostname lookup
only when psutil is unavailable.

stop_lan_listener no longer releases ownership on the event-loop branch.
/api/shutdown reaches it from a task on the serving loop, and uvicorn cannot
close the sockets until that loop is free, which _graceful_shutdown then holds
for seconds while it stops the inference, export and training subprocesses. Only
_bound_addresses is dropped there, so the frontend gate closes at once while the
sockets and the beyond-loopback trust flag stay owned until they really close.
@mahiatlinux

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 149bf9ae04

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread studio/backend/lan_access.py
Closing the listening sockets stops new connections, but uvicorn then drains the
ones it already accepted. Clearing the beyond-loopback flag at that point let a
LAN request still executing reach stdio_mcp_enabled() in
core/inference/mcp_client.py and be treated as loopback-only, so a remote caller
could spawn stdio MCP subprocesses under the local default. Stopping LAN access
while a long inference from a phone is in flight is an ordinary way to hit it.

The confirmed-close path now releases the listener references without the flag
and hands it to a drain watcher, which clears it once server_state.connections
empties. The watcher leaves the flag active if the connections never drain, and
skips the clear when a new listener has since taken ownership. The Stop response
itself still returns as soon as the sockets close.
@mahiatlinux

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ef8705df61

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread studio/backend/lan_access.py
A second stop arriving after the first closed the sockets but while accepted
requests were still draining found _server already None and released the flag
outright, so those still-remote requests passed the loopback-default stdio MCP
gate. The idempotent stop path bypassed the drain watcher entirely.

The flag is no longer assigned by any path. _sync_lan_trust publishes it from
the authoritative state, a live listener or any stopped listener still draining,
counted by _pending_drains and taken under the same lock as the references. A
watcher whose connections never drain keeps its count rather than releasing, so
the flag fails closed. start_lan_listener still raises it explicitly before the
socket can accept.
@mahiatlinux

Copy link
Copy Markdown
Collaborator Author

@codex review

@mahiatlinux

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 68ac46ac9c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread studio/backend/lan_access.py Outdated
asyncio.run_coroutine_threadsafe raises RuntimeError("Event loop is closed") if
the primary loop closes between _server_loop validating it and the schedule
call. That escaped past the failure cleanup with every socket already bound and
the trust flag already raised, so the port stayed listening with nothing serving
it, status reported no listener, later starts hit bind_failed, and the flag
stayed on until the process exited.

The cleanup is now shared by both start failure paths in _fail_start, which
closes the sockets, resyncs the trust flag from ownership and records
listener_start_failed. The unscheduled coroutine is closed so the failure does
not also emit a never-awaited warning.
@mahiatlinux

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2b8836a078

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread studio/backend/lan_access.py
Comment thread studio/backend/lan_access.py
…s loop ends

start_lan_listener holds _lock while waiting for the serving loop to run
serve(). /api/shutdown reaches stop_lan_listener from a task on that same loop,
so blocking on the lock left the two waiting each other out: the loop could not
advance the scheduled listener, the start burned its full timeout and shutdown
stalled with it. A stop running on the serving loop now takes the lock without
blocking and reports an unconfirmed stop instead.

The event-loop branch keeps ownership so uvicorn can close the sockets once the
loop is free, but nothing released it afterwards. An embedded host that calls
run_server again in-process therefore saw a stale _server, reported the previous
addresses as online and never bound a new listener. run.py's server thread now
releases the listener in the same finally that closes the loop, which reaches
the loop-gone path and drops the sockets, references and trust flag.
@mahiatlinux

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b8f1d669c3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +207 to +210
except Exception as exc:
# an optional preference must never take the whole server down with it
logger.info("LAN access auto-start skipped: %s", exc)
return False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry auto-start after transient network failures

When Studio launches before Wi-Fi, DHCP, or another selected interface is ready, start_lan_access() records no_lan_address/bind_failed, but this catch permanently abandons the enabled auto-start preference for the rest of the process. The five-second status polling only reads that error and never retries, so LAN access remains off until the user starts it manually despite “Start automatically” being enabled; schedule retries for transient startup failures once the server and network become available.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not taking this one. Nothing is lost or wrong when it happens: the failure is recorded, lan_access_status reports state error with no_lan_address, the section renders 'No network address found. Connect this machine to Wi-Fi or a wired network, then try again.' and can_start stays true, so recovery is one click on a control that is already on screen. maybe_auto_start_remote_access gives up the same way, so retrying only here would split the two. A retry scheduler is also new background machinery touching the listener lock and the trust flag, which is where the last six rounds of findings have been, and that risk is not worth removing one click on a startup race.

@mahiatlinux

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: b8f1d669c3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@mahiatlinux mahiatlinux self-assigned this Aug 17, 2026
@mahiatlinux

mahiatlinux commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Before/after UI evidence

Two isolated Studio installs, one at this PR's merge base 6f443b5cc and one at its head b8f1d669c, each built with install.sh --local under its own UNSLOTH_STUDIO_HOME and driven through the same script. Both launched plainly (unsloth studio -p <port>, default 127.0.0.1 bind, no --secure), so the launch policy is identical on the two halves and the only difference is the code.

Settings > API keys, at rest. Remote access above is the control: it is untouched by this PR and renders identically on both sides.

Settings > API keys, section absent vs present

BEFORE goes straight from Remote access to Model auto-switch (OpenAI API). AFTER has the new card between them: Wi-Fi glyph, grey Off dot, "Use Unsloth and its APIs from other devices on your Wi-Fi or wired network.", a Start button, and a Start automatically switch.

The same panel after Start is pressed. BEFORE has no button to press, so its half is byte-identical to the shot above.

The same panel after Start

AFTER reads LAN access · Online · Settings managed on a green dot, the button flips to Stop, and a Network address block appears with the bound URL in a code field beside QR and Copy, under the "Anyone on this network who has the password or an API key can sign in and run code on this machine." warning.

Numbers, read from the same servers that were photographed

GET /api/settings/lan-access:

BEFORE AFTER
at rest 404 state=off, can_start=true, can_stop=false, block_reason=null, serves_web_ui=true
after Start 404 state=online, managed_by=settings, urls=[http://192.168.4.70:8998], public_urls=[], can_stop=true
after Stop 405 state=off, urls=[]

The 405 on BEFORE is the route not existing rather than a refusal.

The picture only shows that a section appeared, so the same address was probed on both halves at each step. http://192.168.4.70:<studio port>/healthz:

BEFORE AFTER
before Start connection refused connection refused
after Start connection refused 200
after Stop connection refused connection refused

So the loopback launch really was loopback-only on both sides, the runtime listener really binds the machine's own address at the same port, and stop_lan_listener really releases it. The loopback socket kept serving throughout: the browser driving the panel was on 127.0.0.1 the whole time. Nothing is left bound after the run.

One detail worth knowing for anyone reproducing this. The host carries 192.168.4.70 plus eight idle docker/lxc bridge addresses, and psutil 7.2.2 is installed in the Studio venv and lists all nine, but every bridge reports isup=False with no containers attached, so _interface_addresses drops them and the section reads Network address singular. A box with a container running would show more rows.

scripts/pr_ui_diff.py --pr 8951, scene lan_access_settings. Both shots are element screenshots of the settings dialog's scroll column at an 800x940 viewport, so the two halves share identical geometry and neither was rescaled to match the other.

Downloadable copy

Same files, packaged by a disposable workflow on pr-8951-evidence-ci so they can be pulled down rather than squinted at inline. Run: https://github.com/mahiatlinux/unsloth/actions/runs/32025526880 (green). Artifact: pr8951-evidence, 493 KB.

  • pr8951-before-after-settings-section.png — the first composite above, BEFORE | AFTER of Settings > API keys at rest
  • pr8951-before-after-lan-started.png — the second composite, the same panel after Start
  • before-api-keys-panel.png, before-api-keys-panel-started.png — the raw merge-base halves, byte-identical to each other
  • after-api-keys-panel.png, after-api-keys-panel-started.png — the raw head halves
  • facts.json — every API reading and socket probe in the tables above, for both sides, with the two SHAs they were taken from
  • SHA256SUMS.txt — generated in the run, over the seven files above

The run asserts the bundle before publishing it: PNG magic bytes on all six images, and facts.json still reporting lan_section_present 0 → 1 and the probe going refused → 200 → refused. No GIF, because this scene recorded stills rather than a video, and a cadence assembled after the fact would not be a recording of anything.

@Etherll Etherll left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM ! Works locally

@Etherll
Etherll merged commit ea687ef into unslothai:main Aug 18, 2026
46 of 51 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 631de3e09e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +91 to +92
if parsed.is_loopback or parsed.is_link_local or parsed.is_multicast:
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow usable IPv4 link-local LAN addresses

On a direct wired or ad-hoc network without DHCP, both devices may have only 169.254/16 IPv4 link-local addresses, which are reachable by peers on the same link. Filtering those addresses here makes detect_lan_addresses() return no candidates, so Settings reports no_lan_address even though the phone or laptop can reach the host; retain IPv4 link-local addresses when enumerating LAN listeners.

Useful? React with 👍 / 👎.

mahiatlinux added a commit to mahiatlinux/unsloth that referenced this pull request Aug 18, 2026
remote_connector_active answers for the cloudflare tunnel and, since unslothai#8951, for
the LAN listener too. access_exposure read that single predicate and called both
public_url, so turning keyless access on behind LAN access told the operator
anyone with their public URL was being let in when the reach was the local
network, and there may be no public URL at all.

host_policy grows tunnel_connector_active and lan_connector_active beside the
combined reader, which keeps its meaning for the stdio MCP gate. access_exposure
asks the tunnel for public_url and the LAN listener for network, so a tunnel
still wins when both are up.
Etherll added a commit that referenced this pull request Aug 25, 2026
* serve the api without a key when the user opts in

Studio can only be used from another app after minting an API key in Settings.
LM Studio and Ollama serve their endpoints with no credential, so pointing an
existing OpenAI-compatible tool at Unsloth means a detour to create and paste a
key first.

Add a Keyless API access card to Settings > API with two switches, both off by
default: one serves the OpenAI-compatible endpoints, the other serves every
route. Splitting them means opening up chat does not also hand over training and
files. Turning either on opens a confirmation naming who it lets in, worded from
how far the server currently reaches; the bind address never blocks the choice.

With a scope on, a request that sends no usable credential authenticates as the
local admin: no Authorization header, a dummy bearer (the OpenAI SDKs always
send one), a malformed header, or a stale key. A key that still works keeps
authenticating as itself, so per-key attribution survives.

Sign-in is untouched: a session token this install knows stays authoritative, so
an expired one still fails and the app re-authenticates. Keyless callers count
as API callers, so every guard that already refuses an sk-unsloth key refuses
them too, including the saved-provider-credential rules in routes/inference.py.

The inference scope names its endpoints one by one because main.py aliases the
whole inference router under /v1, where a prefix match would also have opened
model loading, media generation and the sandbox routes.

* keep tools, key minting and the ui switch behind their own gates

Server-side tools stay off for a keyless caller until the admin ticks "Allow
tools", with its own confirmation. /v1/chat/completions drives the python and
terminal tool loop on this machine and `unsloth studio run` turns tools on by
default, so serving that route without a key would otherwise hand the loop to
anyone who can reach it. KeylessToolPolicyMiddleware applies the same
tools_force_disabled() the public /p surface already uses.

Only a signed-in UI session may change the setting: an sk-unsloth key must not
be able to switch authentication off for the whole install, and a keyless caller
must not be able to widen its own scope.

_request_used_api_key now counts a keyless caller, so the API monitor opens on
that traffic instead of filing it as the owner's own chat.

The usage-examples snippets take the scope rather than a boolean; the coding
agent reaches past /v1, so its command only drops the real key under full.

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

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

* let a working api key keep its tools, and refuse keyless key minting

KeylessToolPolicyMiddleware read every bearer that is not a Studio session as
keyless, so a valid sk-unsloth key hit tools_force_disabled() whenever keyless
inference was on with the tool grant off. That took tools away from API clients
that already had them. asgi_request_is_keyless now also asks whether the bearer
validates as a key, matching what _get_current_credential does with it a moment
later. A revoked or expired key still falls through to the keyless admin, so it
keeps losing tools.

Under the full scope an anonymous POST /api/auth/api-keys resolved to the admin
credential and minted an sk-unsloth key that went on working after keyless
access was switched off. admitted_without_credential is the narrower form of
is_keyless: true only when the setting itself is what admitted the caller. A
signed-in session and a working key still mint keys as before.

* drop the formatter churn from the two touched settings files

usage-examples.tsx and api-keys-tab.tsx came back through biome at its default
80 columns, and the repo is not formatted to that width. The rewrap split the
fromCatalog expression that test_usage_examples_model_source_contract.py reads
verbatim, failing test_examples_name_a_model_the_server_can_serve and
test_standalone_idle_unload_still_names_the_stored_checkpoint. Both files now
carry only the keylessScope wiring.

* give the tool snippets a key that actually gets tools

The tools and advanced variants ask for the built-in python, terminal and web
search tools, and KeylessToolPolicyMiddleware refuses them to a keyless caller
until the admin ticks the tool grant on. Building all nine snippets from one key
meant that with keyless access on and the grant off, a copied tools example ran
as plain chat with no sign anything had been dropped.

buildSnippets now takes a second key for the six tool-requesting variants, and
the panel only puts the dummy key there once the grant is on. KeylessApiAccessSection
reports the tool state alongside the scope so the panel can see it.

* do not let a stale refresh reopen the scope a write just closed

_settings() published whatever _read_settings() returned. A sqlite read blocks,
so a request could be holding the open scope when a concurrent PUT wrote "off"
and cached it, then overwrite that with its stale answer. Every request for the
rest of the 1s TTL still authenticated as the admin with no key, after the call
that closed access had already returned. The two settings also come back through
separate connections, so the same interleaving tears the scope from the tool
grant.

Writes now bump a generation under a lock, and a refresh only publishes when the
generation it read under is still current, otherwise it returns the value the
write published. test_a_refresh_that_raced_the_closing_write_cannot_republish_the_open_scope
drives the interleaving with an event pair and fails on the old code.

* keep keyless off credential management, and let it reach the sandbox files

Revoking a key outlives the setting exactly as minting one does: switching keyless
access off does not restore a key it destroyed, so an anonymous caller under the
full scope could permanently break every authenticated API client. The guard the
mint route carried is now a shared dependency on all three /api-keys routes, with
listing included because it is the step that names the key to revoke.

The three /api/inference/sandbox routes resolve the bearer themselves, out of the
Authorization header or the ?token= query param an <img src> has to use, so
`security` never sees them and they answered 401 under a scope that covers them.
credentials_for_token builds what the dependency would have, so those routes now
follow the full scope, stay outside the inference scope, and keep treating a
session token this install knows as authoritative on the query path too.

* stop the middleware key check stamping a use and taking the write lock

bearer_is_valid_api_key went through validate_api_key, which updates last_used_at
inside a BEGIN IMMEDIATE. _get_current_credential then validates the same bearer
again a moment later, so every API-key request on a keyless-scoped path counted as
two uses and took sqlite's global write lock twice. The stamp belongs to the real
validation, not to the advisory check the tool-policy middleware makes on the way
past.

validate_api_key_with_credential takes touch=False for that caller: same lookup,
same expiry and active checks, no write and no write transaction. Every existing
caller keeps the default, so the generation a route binds a write to still comes
from the transaction that read it.

* hold keyless callers back from downloads and sign-outs, and say what full grants

Three effects a keyless caller should not reach.

With auto-download already on, an unauthenticated request could name any public Hub
repo and make the server fetch it, which the inference dialog does not offer: it
says the caller gets the loaded model. _maybe_auto_download_model now stops there,
past the cheap disqualifiers so the bearer check only runs where a download would
otherwise start, and the request falls through to the resident model as it does
with auto-download off. A key still starts one, since that is a credential the
owner issued.

POST /api/auth/logout revokes every refresh token for the subject, and those do
not come back when keyless access is switched off, so any reachable caller could
sign the UI out again and again. It takes the guard the API-key routes already
have, now a small factory so each route names itself in the 403.

The full-scope confirmation said callers could read the files and settings. That
scope serves every route, delete endpoints included, so the dialog now says read,
change and delete at the point the operator consents to it.

* report a lan listener as network reach, not a public url

remote_connector_active answers for the cloudflare tunnel and, since #8951, for
the LAN listener too. access_exposure read that single predicate and called both
public_url, so turning keyless access on behind LAN access told the operator
anyone with their public URL was being let in when the reach was the local
network, and there may be no public URL at all.

host_policy grows tunnel_connector_active and lan_connector_active beside the
combined reader, which keeps its meaning for the stdio MCP gate. access_exposure
asks the tunnel for public_url and the LAN listener for network, so a tunnel
still wins when both are up.

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

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

* stop a keyless caller switching the loaded model, not just downloading one

The guard added in 50ed867 sat in _maybe_auto_download_model, which
_maybe_auto_switch_model only reaches once local resolution misses. With Switch
model by request on and several GGUFs already downloaded, a keyless caller could
still name any indexed one and evict the operator's active model, over and over.
Downloads were blocked, local swaps were not.

It moves up to _maybe_auto_switch_model, where clearing auto_switch_on gives the
caller exactly the behaviour of the switch being off: the named model must be the
one already serving or the request is refused by _reject_unservable_model, never
answered by a different model, and no download follows either. The reload stash
still runs, so an idle-unloaded model comes back for keyless chat.

Placed after the _loaded_identity_satisfies check so the ordinary request that
names the serving model returns before anything validates a bearer.

* answer /api/health in full for a caller the scope admits

health_check reads its own bearer, so _BearerOrKeyless never saw it: no header
returned the unauthenticated payload outright, and a dummy key was wrapped in a
plain Bearer credential that get_current_subject then refused. Under the full
scope a keyless caller was left without version, device_type, cloudflare_url and
server_url on a route that scope covers.

It now resolves through credentials_for_token, the same helper the sandbox
handlers use. The authed fields stay behind a credential everywhere else: with
keyless off there is nothing to stand in for one, and /api/health is not part of
the inference scope.

* refuse external provider routing to a caller admitted without a credential

/v1/chat/completions is in the inference scope, and its external-provider branch
runs before anything touches the local model. provider_type with a caller-supplied
provider_base_url needs no key, since llama.cpp, vLLM and Ollama commonly run
without one, and validate_provider_base_url deliberately allows loopback and LAN
so those work. Together that let an unauthenticated caller make this host issue
requests to whatever it can reach, which is nothing like the loaded model the
inference dialog offers.

The branch now refuses a caller the setting alone admitted. A key or a signed-in
session still routes as before, and the check costs nothing on a request that
names no provider. Saved credentials were already withheld separately, so this
closes the egress rather than the credential.

* Studio: harden keyless LAN API access

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

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

* Studio: stabilize keyless auth test collection

* Studio: compact keyless access regression tests

* Studio: keep keyless auth lookup off event loop

* Studio: linearize keyless settings reads

* Studio: recheck tools after keyless admission

* Studio: publish refreshed keyless settings

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

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

* Studio: close remaining keyless review gaps

* Studio: preserve compact keyless test matrix

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

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

* Studio: finish keyless review convergence

* Studio: harden keyless model restore and token lookup

* Tests: keep PR coverage at the line budget

* fix keyless credential and listener boundaries

* fix keyless usage examples

* serialize keyless settings writes

* close keyless hosted access gaps

* tighten keyless credential boundaries

* Tests: align keyless MCP CI scaffolding

* Studio: show keyless access under Remote and LAN

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Etherll <61019402+Etherll@users.noreply.github.com>
danielhanchen pushed a commit that referenced this pull request Aug 25, 2026
* serve the api without a key when the user opts in

Studio can only be used from another app after minting an API key in Settings.
LM Studio and Ollama serve their endpoints with no credential, so pointing an
existing OpenAI-compatible tool at Unsloth means a detour to create and paste a
key first.

Add a Keyless API access card to Settings > API with two switches, both off by
default: one serves the OpenAI-compatible endpoints, the other serves every
route. Splitting them means opening up chat does not also hand over training and
files. Turning either on opens a confirmation naming who it lets in, worded from
how far the server currently reaches; the bind address never blocks the choice.

With a scope on, a request that sends no usable credential authenticates as the
local admin: no Authorization header, a dummy bearer (the OpenAI SDKs always
send one), a malformed header, or a stale key. A key that still works keeps
authenticating as itself, so per-key attribution survives.

Sign-in is untouched: a session token this install knows stays authoritative, so
an expired one still fails and the app re-authenticates. Keyless callers count
as API callers, so every guard that already refuses an sk-unsloth key refuses
them too, including the saved-provider-credential rules in routes/inference.py.

The inference scope names its endpoints one by one because main.py aliases the
whole inference router under /v1, where a prefix match would also have opened
model loading, media generation and the sandbox routes.

* keep tools, key minting and the ui switch behind their own gates

Server-side tools stay off for a keyless caller until the admin ticks "Allow
tools", with its own confirmation. /v1/chat/completions drives the python and
terminal tool loop on this machine and `unsloth studio run` turns tools on by
default, so serving that route without a key would otherwise hand the loop to
anyone who can reach it. KeylessToolPolicyMiddleware applies the same
tools_force_disabled() the public /p surface already uses.

Only a signed-in UI session may change the setting: an sk-unsloth key must not
be able to switch authentication off for the whole install, and a keyless caller
must not be able to widen its own scope.

_request_used_api_key now counts a keyless caller, so the API monitor opens on
that traffic instead of filing it as the owner's own chat.

The usage-examples snippets take the scope rather than a boolean; the coding
agent reaches past /v1, so its command only drops the real key under full.

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

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

* let a working api key keep its tools, and refuse keyless key minting

KeylessToolPolicyMiddleware read every bearer that is not a Studio session as
keyless, so a valid sk-unsloth key hit tools_force_disabled() whenever keyless
inference was on with the tool grant off. That took tools away from API clients
that already had them. asgi_request_is_keyless now also asks whether the bearer
validates as a key, matching what _get_current_credential does with it a moment
later. A revoked or expired key still falls through to the keyless admin, so it
keeps losing tools.

Under the full scope an anonymous POST /api/auth/api-keys resolved to the admin
credential and minted an sk-unsloth key that went on working after keyless
access was switched off. admitted_without_credential is the narrower form of
is_keyless: true only when the setting itself is what admitted the caller. A
signed-in session and a working key still mint keys as before.

* drop the formatter churn from the two touched settings files

usage-examples.tsx and api-keys-tab.tsx came back through biome at its default
80 columns, and the repo is not formatted to that width. The rewrap split the
fromCatalog expression that test_usage_examples_model_source_contract.py reads
verbatim, failing test_examples_name_a_model_the_server_can_serve and
test_standalone_idle_unload_still_names_the_stored_checkpoint. Both files now
carry only the keylessScope wiring.

* give the tool snippets a key that actually gets tools

The tools and advanced variants ask for the built-in python, terminal and web
search tools, and KeylessToolPolicyMiddleware refuses them to a keyless caller
until the admin ticks the tool grant on. Building all nine snippets from one key
meant that with keyless access on and the grant off, a copied tools example ran
as plain chat with no sign anything had been dropped.

buildSnippets now takes a second key for the six tool-requesting variants, and
the panel only puts the dummy key there once the grant is on. KeylessApiAccessSection
reports the tool state alongside the scope so the panel can see it.

* do not let a stale refresh reopen the scope a write just closed

_settings() published whatever _read_settings() returned. A sqlite read blocks,
so a request could be holding the open scope when a concurrent PUT wrote "off"
and cached it, then overwrite that with its stale answer. Every request for the
rest of the 1s TTL still authenticated as the admin with no key, after the call
that closed access had already returned. The two settings also come back through
separate connections, so the same interleaving tears the scope from the tool
grant.

Writes now bump a generation under a lock, and a refresh only publishes when the
generation it read under is still current, otherwise it returns the value the
write published. test_a_refresh_that_raced_the_closing_write_cannot_republish_the_open_scope
drives the interleaving with an event pair and fails on the old code.

* keep keyless off credential management, and let it reach the sandbox files

Revoking a key outlives the setting exactly as minting one does: switching keyless
access off does not restore a key it destroyed, so an anonymous caller under the
full scope could permanently break every authenticated API client. The guard the
mint route carried is now a shared dependency on all three /api-keys routes, with
listing included because it is the step that names the key to revoke.

The three /api/inference/sandbox routes resolve the bearer themselves, out of the
Authorization header or the ?token= query param an <img src> has to use, so
`security` never sees them and they answered 401 under a scope that covers them.
credentials_for_token builds what the dependency would have, so those routes now
follow the full scope, stay outside the inference scope, and keep treating a
session token this install knows as authoritative on the query path too.

* stop the middleware key check stamping a use and taking the write lock

bearer_is_valid_api_key went through validate_api_key, which updates last_used_at
inside a BEGIN IMMEDIATE. _get_current_credential then validates the same bearer
again a moment later, so every API-key request on a keyless-scoped path counted as
two uses and took sqlite's global write lock twice. The stamp belongs to the real
validation, not to the advisory check the tool-policy middleware makes on the way
past.

validate_api_key_with_credential takes touch=False for that caller: same lookup,
same expiry and active checks, no write and no write transaction. Every existing
caller keeps the default, so the generation a route binds a write to still comes
from the transaction that read it.

* hold keyless callers back from downloads and sign-outs, and say what full grants

Three effects a keyless caller should not reach.

With auto-download already on, an unauthenticated request could name any public Hub
repo and make the server fetch it, which the inference dialog does not offer: it
says the caller gets the loaded model. _maybe_auto_download_model now stops there,
past the cheap disqualifiers so the bearer check only runs where a download would
otherwise start, and the request falls through to the resident model as it does
with auto-download off. A key still starts one, since that is a credential the
owner issued.

POST /api/auth/logout revokes every refresh token for the subject, and those do
not come back when keyless access is switched off, so any reachable caller could
sign the UI out again and again. It takes the guard the API-key routes already
have, now a small factory so each route names itself in the 403.

The full-scope confirmation said callers could read the files and settings. That
scope serves every route, delete endpoints included, so the dialog now says read,
change and delete at the point the operator consents to it.

* report a lan listener as network reach, not a public url

remote_connector_active answers for the cloudflare tunnel and, since #8951, for
the LAN listener too. access_exposure read that single predicate and called both
public_url, so turning keyless access on behind LAN access told the operator
anyone with their public URL was being let in when the reach was the local
network, and there may be no public URL at all.

host_policy grows tunnel_connector_active and lan_connector_active beside the
combined reader, which keeps its meaning for the stdio MCP gate. access_exposure
asks the tunnel for public_url and the LAN listener for network, so a tunnel
still wins when both are up.

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

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

* stop a keyless caller switching the loaded model, not just downloading one

The guard added in 50ed867 sat in _maybe_auto_download_model, which
_maybe_auto_switch_model only reaches once local resolution misses. With Switch
model by request on and several GGUFs already downloaded, a keyless caller could
still name any indexed one and evict the operator's active model, over and over.
Downloads were blocked, local swaps were not.

It moves up to _maybe_auto_switch_model, where clearing auto_switch_on gives the
caller exactly the behaviour of the switch being off: the named model must be the
one already serving or the request is refused by _reject_unservable_model, never
answered by a different model, and no download follows either. The reload stash
still runs, so an idle-unloaded model comes back for keyless chat.

Placed after the _loaded_identity_satisfies check so the ordinary request that
names the serving model returns before anything validates a bearer.

* answer /api/health in full for a caller the scope admits

health_check reads its own bearer, so _BearerOrKeyless never saw it: no header
returned the unauthenticated payload outright, and a dummy key was wrapped in a
plain Bearer credential that get_current_subject then refused. Under the full
scope a keyless caller was left without version, device_type, cloudflare_url and
server_url on a route that scope covers.

It now resolves through credentials_for_token, the same helper the sandbox
handlers use. The authed fields stay behind a credential everywhere else: with
keyless off there is nothing to stand in for one, and /api/health is not part of
the inference scope.

* refuse external provider routing to a caller admitted without a credential

/v1/chat/completions is in the inference scope, and its external-provider branch
runs before anything touches the local model. provider_type with a caller-supplied
provider_base_url needs no key, since llama.cpp, vLLM and Ollama commonly run
without one, and validate_provider_base_url deliberately allows loopback and LAN
so those work. Together that let an unauthenticated caller make this host issue
requests to whatever it can reach, which is nothing like the loaded model the
inference dialog offers.

The branch now refuses a caller the setting alone admitted. A key or a signed-in
session still routes as before, and the check costs nothing on a request that
names no provider. Saved credentials were already withheld separately, so this
closes the egress rather than the credential.

* Studio: harden keyless LAN API access

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

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

* Studio: stabilize keyless auth test collection

* Studio: compact keyless access regression tests

* Studio: keep keyless auth lookup off event loop

* Studio: linearize keyless settings reads

* Studio: recheck tools after keyless admission

* Studio: publish refreshed keyless settings

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

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

* Studio: close remaining keyless review gaps

* Studio: preserve compact keyless test matrix

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

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

* Studio: finish keyless review convergence

* Studio: harden keyless model restore and token lookup

* Tests: keep PR coverage at the line budget

* fix keyless credential and listener boundaries

* fix keyless usage examples

* serialize keyless settings writes

* close keyless hosted access gaps

* tighten keyless credential boundaries

* Tests: align keyless MCP CI scaffolding

* Studio: show keyless access under Remote and LAN

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Etherll <61019402+Etherll@users.noreply.github.com>
@CoderChang65535

CoderChang65535 commented Aug 26, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants