studio: add settings-managed LAN access - #8951
Conversation
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.
for more information, see https://pre-commit.ci
There was a problem hiding this comment.
💡 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".
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.
There was a problem hiding this comment.
💡 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".
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.
|
@codex review |
for more information, see https://pre-commit.ci
There was a problem hiding this comment.
💡 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".
…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.
|
@codex review |
for more information, see https://pre-commit.ci
There was a problem hiding this comment.
💡 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".
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.
|
@codex review |
for more information, see https://pre-commit.ci
There was a problem hiding this comment.
💡 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".
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.
|
@codex review |
for more information, see https://pre-commit.ci
|
@codex review |
There was a problem hiding this comment.
💡 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".
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.
|
@codex review |
for more information, see https://pre-commit.ci
There was a problem hiding this comment.
💡 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".
…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.
|
@codex review |
for more information, see https://pre-commit.ci
There was a problem hiding this comment.
💡 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".
| 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 |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
|
@codex review |
|
Codex Review: Didn't find any major issues. You're on a roll. Reviewed commit: ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
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". |
Before/after UI evidenceTwo isolated Studio installs, one at this PR's merge base Settings > API keys, at rest. BEFORE goes straight from The same panel after AFTER reads Numbers, read from the same servers that were photographed
The The picture only shows that a section appeared, so the same address was probed on both halves at each step.
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 One detail worth knowing for anyone reproducing this. The host carries
Downloadable copySame files, packaged by a disposable workflow on
The run asserts the bundle before publishing it: PNG magic bytes on all six images, and |
There was a problem hiding this comment.
💡 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".
| if parsed.is_loopback or parsed.is_link_local or parsed.is_multicast: | ||
| return |
There was a problem hiding this comment.
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 👍 / 👎.
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.
* 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>
* 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>



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. Eachhttp://<address>:<port>is shown with a copy button and a QR code, and Start automatically brings it up at every launch.How it works
studio/backend/lan_access.pyowns 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_listenerschedulesServer.serve(sockets=...)onto the primary loop withasyncio.run_coroutine_threadsafe.stop_lan_listenerwaits on the sockets, not onserve(). uvicorn closes the sockets passed to it at the top ofshutdown()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.pyholds the launch policy and the persistedlan_access_auto_startpreference. Start is blocked on Colab, on a--securelaunch (run.pyforces 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 aspublic_urlsso the section says so instead of calling it a local network.main.py's_is_remote_frontend_requestopens the desktop SPA gate for requests arriving on a LAN listener socket, identified byscope["server"]rather than any client header, so a desktop--api-onlybackend serves its packaged web UI over LAN without changing the local api-only surface.utils/host_policy.pytracks 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 intest_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, includingtests/lan-access-state.test.tsnpm run typecheck,npm run build,npm run i18n:check:strict,ruff check,biome check— clean--api-onlylaunch, where loopback/stays 404 and the LAN address serves the SPA.