unsloth/studio/frontend/tests/python-tool-image-path.test.ts
Daniel Han ba4f7274a1
Studio: show the files a tool call creates, and keep them in one place (#8256)
* Studio: show the files a tool call creates, and keep them in one place

Files written by the python or terminal tool were unreachable from the chat:
only images could be fetched back, nothing could list a chat's files, the
terminal tool reported none at all, and the model was never told what happens to
what it writes, so it guessed at a temporary directory and users had to search
the disk.

- both executors report created files via a __FILES__ sentinel, stripped before
  the model sees it
- non-images download as attachments, images still render inline
- GET /sandbox/{session_id} lists a chat's files and its path
- tool cards get a 'files created' row with working downloads
- the sandbox moves under the studio home (migrated, overridable) instead of a
  third folder in the user's home
- UNSLOTH_COMPILE_LOCATION is pinned before unsloth_zoo reads it, so the
  compiled cache stops landing in the launcher's working directory
- deleting a chat cleans up its sandbox

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

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

* Do not create a sandbox from a read, and reject reserved names

Serving or listing a file resolves the sandbox path instead of creating it, so
a GET no longer leaves a folder behind for every id it is asked about. Session
ids matching a Windows device name (CON, NUL, COM1) now collapse to _invalid,
and the compiled-cache cleanup follows the configured location rather than only
the defaults.

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

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

* Cover the cases the first pass missed

Files written into a subdirectory are now found, listed and downloadable, and
a run that wrote its file before timing out or being cancelled still reports it.
Clearing all chats removes their sandboxes like deleting them one by one does.
The legacy migration is serialised and runs for a read too, so an upgraded
install does not 404 until some tool happens to run. The image list is capped
like the file list, the __FILES__ envelope is only stripped when it parses, and
a direct `uvicorn main:app` start pins the compiled-cache location as well.

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

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

* Replay the tool text, and keep the sentinel honest

A file-producing call was replayed to the model as the whole card object rather
than the stdout it saw; the same shape was already reaching that path for image
results, so both go through their text now. The snapshot walk and the download
route share one segment limit, so the card cannot advertise a file the route
would refuse, and both ends of the __FILES__ envelope check each entry rather
than only that it is a list. Size joins mtime in the change key for volumes with
coarse timestamps.

* Drop the duplicate import the main merge left behind

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

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

* Report exactly what the route will serve

The snapshot and the listing apply the download route's own per-segment
allowlist, so a name containing a backslash or a control character is never
offered as a chip that would then 404. Dotfiles a tool wrote are reported now,
since .gitignore is a real artifact and the route serves it; dot-directories
stay out, which is where the noise lives. Deleting a chat runs the legacy
migration first, so a delete before any tool has run still finds the folder.

* Do not delete what is not ours, and do not serve what is not inside

UNSLOTH_COMPILE_LOCATION is user-set, and routing the cleanup through it let a
value naming a shared directory take that whole tree at startup; a configured
path now has to look like a compiled cache before anything deletes from it. The
read-only resolver applies the containment check the executing one always had,
so a session entry that is a symlink out of the root no longer becomes the root
and serves its target. The listing walk drops directory segments the download
route would refuse, and executor scratch no longer keeps a deleted chat's folder
alive forever.

* Unwrap the result everywhere it is read, not just on replay

The export paths feed fine-tuning datasets, and they serialised the card wrapper
whole; they share one helper with replay now, which also covers the image shape
they never unwrapped either. A file the user named studio_exec_results.csv is no
longer treated as the executor's scratch, which is specifically
studio_exec_<random>.py. And a sandbox root Studio did not create keeps its
permissions, since the override can name a shared directory.

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

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

* Prove the cache is ours before deleting it

A directory of plain .py files is somebody's package, so the shape test was
still too loose: Studio writes a marker file when it creates the location, and a
configured path needs that marker or a generated module name before anything
deletes from it. Containment moved into one helper applied to cached session
paths too, so a directory swapped for a symlink after it was cached stops
resolving there.

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

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

* Delete only our own files from a shared compile location

A directory holding a generated module was treated as ours whole, so a
UNSLOTH_COMPILE_LOCATION pointing at a shared directory lost its siblings.
Only a directory Studio created (marker) is cleared whole; anywhere else
just the generated modules go.

Also cap directories visited during the sandbox snapshot, and treat a
RecursionError from an oversized __FILES__ payload like the invalid JSON
it is.

* Bound the listing walk, and retry a legacy move that failed

The listing route grew its own copy of the walk and drifted from the
snapshot: no directory budget, a prefix match that hid the user's own
studio_exec_results.csv, and a hardcoded file cap. It now shares all three
with the snapshot walk.

Migration flagged itself complete even when a move raised, so a file
locked on Windows was stranded once the destination directory appeared.
It reports whether anything movable is left, and only then is it done.

Clear-all-chats takes the same delete_files opt-in as DELETE /threads,
still off by default.

* Never delete through a session symlink, and keep the cache marker

A session entry replaced by a symlink to a sibling passed the realpath
containment check, so deleting that chat took the other one's files. The
link itself is dropped and its target left alone.

Startup clears the cache it just created, and nothing rewrote the marker
afterwards, so the next cleanup demoted our own cache to shared. It is
restored after clearing a directory we own.

A chat's first turn has no thread id yet but still runs in a real workdir
(_default, which the UI and the download route both resolve), so its files
get a download chip too. The model- and API-facing message is still
stripped, which is the boundary that has to stay clean.

* Drop a stray file a local test run left in the tree

* Reserve no filename, and prove ownership of a CWD cache

The executor's scratch script moves out of the sandbox, so a tool writing
studio_exec_results.py keeps it: nothing in the sandbox belongs to Studio
except the remap sidecar it writes itself, which is excluded by exact name.
That sidecar was being reported as a user file, which also made a streamed
result differ from a non-streamed one.

A folder named unsloth_compiled_cache in the launch directory is no longer
ours by construction; it needs the marker or generated modules like any
other configured location.

Containment uses commonpath, so a sandbox root that is a filesystem or
volume root no longer sends every chat to _invalid.

* Put the scratch script back in the sandbox, and gate first-turn files

Moving it to the system temp directory changed sys.path[0], so a module an
earlier call wrote stopped importing, __file__ pointed outside the sandbox,
and the script's directory became world-writable /tmp. It is back in the
workdir; the reporting exclusion is now this call's exact filename rather
than a pattern reserved over user names.

A turn with no chat id runs in the shared _default workdir, so reporting its
files pinned a card to a directory the next new chat would overwrite. Gated
again until a per-chat sandbox exists.

register_compiled_cache_on_path applies the same ownership test as cleanup,
so an unrelated folder in the launch directory cannot shadow dependencies.

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

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

* Do not unlink a sandbox a tool is using, or claim a directory we found

Deleting a chat mid-call removed the empty workdir out from under the
running tool, and a process whose cwd is gone fails every relative write.
Sessions with a call in flight are tracked and refuse removal.

mkdir(exist_ok=True) does not mean Studio created the directory, but the
marker that followed licensed an rmtree of it. It is written only when
this call actually made the directory.

* Decide and delete under one lock, and offer chats the same file choice

The busy check released the lock before touching the filesystem, so a tool
could start in the window and end up running in a directory the same call
then removed. The check and the unlink are one critical section.

Chat delete now offers the same switch project delete has, so a chat that
wrote files is not left with an unreachable folder. Off by default, and the
sandbox is only removed when the user asks.

* Rename the delete switch state now that chats use it too

* Queue a refused delete, and only offer the switch where it works

A delete refused because a tool was running was dropped, and the thread was
already gone from history, so nothing would ever name that session again. It
is queued and performed when the call ends.

The delete switch appeared for training runs and for chats inside a project,
where nothing it promises happens. It is shown only where a sandbox can
actually be removed, and every opener clears it so it cannot arrive
preselected from a cancelled dialog.

rmtree refuses a symlink and ignore_errors hid it, so a symlinked
UNSLOTH_COMPILE_LOCATION was never cleared. Resolved first.

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

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

* Tighten the comments in the sandbox and cache paths

* Prove ownership before following a cache link, and contain the fallbacks

Resolving the cache path made rmtree follow a link, so a built-in path that
is a symlink would have taken its target with it. Being at a built-in path
proves ownership of the directory, not of what a link there points at; only
the marker on the target does.

_default and _invalid are ordinary directories in a writable sandbox, so one
replaced by a symlink became the root every unchecked request read from.
They go through the same containment as a session.

A file delete now detaches the directory under the lock and removes the tree
afterwards, so a large sandbox no longer blocks every tool start or the
event loop of the route that asked.

* Finish a detached delete a restart interrupted

The detached directory sits under a name no session id can reach, so an
exit mid-delete stranded it for good, which is the accumulating folder this
change is about. Each delete clears the leftovers of earlier ones.

* Sweep interrupted deletes at startup, and stop guessing at project ids

The delete worker is a daemon, so an exit left the renamed directory with
nothing to reclaim it until someone deleted another chat. Startup sweeps
them once, off the request path.

A session id starting with project- is only a project workspace when the
project exists; an imported chat carrying the prefix gets an ordinary
directory from _get_workdir and was never cleaned up.

Studio's tool wrapper always carries images, so requiring it stops a
foreign result with text and sessionId from being reduced to its text on
replay and on every export path.

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

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

* Name the encoding in two test writes

* Prove a sandbox is ours before deleting it

The root can be an existing shared folder the user pointed us at, so a
chat id can name something already in there. Every session directory we
create now carries a marker, and outside our own root that marker is
what a delete needs.

The tombstone sweep matched any name containing .deleting-, which in
such a root reached a user's own report.deleting-backup. It now matches
only the exact name the rename produces.

Two ids differing only in case are one directory on Windows and on a
default macOS volume, so the in-flight bookkeeping folds them together
while the queued delete still carries the exact id.

* Only ever reclaim what Studio itself created

The marker was written on every resolve, so a chat id naming a folder
that was already in a shared root claimed it. It is written only when
the call created the directory, and a tombstone now needs the marker
too, so a user's own archive.deleting-<hex> is left alone.

Queued deletes are held per exact id under the folded key: on a
case-sensitive filesystem two ids that differ only in case are two
directories, and the second request used to replace the first.

Deletion in a cache directory we do not own is limited to
unsloth_compiled_module_*.py. Unsloth*Trainer.py still identifies a
cache, since an old install can be left holding nothing else, but it is
a name a user's own subclass can carry and is not proof we wrote it.

* Leave a shared root's own entries exactly as they are

Dropping a _default or _invalid symlink is only ours to do at our own
root. Where the user pointed us at a directory of their own, the entry
stays and a fresh one is used instead, so no lookup follows it and
nothing of theirs is unlinked.

A directory that was already there also keeps its permissions: it is
not marked, it is not deleted, and now it is not chmodded to 0700
either.

* Give a directory one owner, and name it in the marker

The marker now records the id the directory was made for. Two ids that
differ only in case are one name on Windows and on a default macOS
volume, so the second one gets a directory of its own instead of
sharing files that either chat's delete would remove, and a delete is
refused outright when the marker names someone else.

A session entry that is a symlink is only unlinked at our own root; in
a directory the user pointed us at it is their entry.

The delete switch said anything this chat's tools wrote. A chat moved
back to Recents wrote its earlier files into the project workspace,
which chat deletion does not touch, so the text now says what it does.

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

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

* Step around folders that were already in a shared root

A tool can write anything into the directory it runs in, so a marker
written there is not evidence. The answer is not to run there at all: a
directory already sitting in a root the user pointed us at is stepped
around, and this chat gets one of its own.

Assignment goes through one claim: the directory is created and the
marker taken with O_CREAT|O_EXCL under a lock, so two case-variant ids
starting at the same moment cannot both take the same name.

A sandbox moved up from the legacy root is claimed as part of the move.
Without that an overridden root read it as someone else's and the chat
could never remove it again.

The terminal card now uses the same wrapper test as the adapter, and a
filename holding a lone surrogate is not advertised: encodeURIComponent
throws on it, so that chip could never download.

* Survive a clobbered marker, and finish a move that stopped part way

A marker holding anything but an id now reads as no owner rather than
as somebody else's, and at our own root the chat takes its directory
back. A tool writing over that file used to send its own chat to a
fresh sandbox on the next launch, with everything it had made left in
the old one.

A destination that exists but was never claimed is a move that was
interrupted, not a collision: a cross-device move copies and then
unlinks, so both sides are left holding files. The rest is moved up on
the next launch, and anything already above wins, the same rule whole
directories follow.

* Claim what we migrate, and answer only for ids a session can have

A destination is claimed as soon as it is recognised as this move
interrupted, so a file that could not come up no longer leaves the
whole directory reading as somebody else's and the chat resolving to an
empty one. In a shared root the move goes to the name the session will
resolve to, unless the plain name holds nothing but a copy of what is
still below, which is this same move rather than a stranger's folder.

A sandbox whose claim did not take is never used: a fresh directory is
made instead, since running there would put the chat inside someone
else's files.

The sandbox listing and download routes now refuse an id no session
could have used. Everything unusable collapses into one _invalid
directory, so answering for those ids handed a caller the files of
every other session that had been given one.

An inherited UNSLOTH_COMPILE_LOCATION= is treated as unset, rather than
pinning the cache to an empty path.

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

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

* Keep the record of where a chat's files are outside the sandbox

Which directory belongs to which session is now written under the
studio home, where tools never run, so a marker one of them deletes or
writes over no longer sends that chat to a fresh directory with its
files left behind. The marker stays as the on-disk claim; the record is
what makes it durable, and either one is enough to delete the folder
again.

Migration provenance comes from that record too. It is written before
the move and dropped when it finishes, so an interruption is the only
thing that leaves one behind, and a directory that merely holds similar
names is no longer mistaken for one of ours.

An id the filesystem cannot hold gets a directory derived from it
rather than a shared _invalid bucket, so those chats stop reading and
deleting each other's files, and their download chips work. The routes
serve them again for the same reason.

The legacy move runs at startup instead of from the first read: across
filesystems it copies every session, which is not something a listing
on the event loop can wait for.

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

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

* Separate the namespaces, and never read a directory that is not the chat's

The derived _id- names are reserved: an id that already looks like one
is derived too, so a literal id can no longer land on the directory of
whichever unusable id hashes to it. Migration bookkeeping moved into
its own object for the same reason, where no session id can name it.

A read decides on what is already there, so it now checks ownership of
the disambiguated name as well and returns a path under a directory we
never create when the answer is no.

An entry that is a symlink is stepped around whether its target is
inside the root or not: claiming through one writes our marker into a
directory somebody else made.

The record outranks the marker when reclaiming, since all marker
contents are writable from the sandbox and a valid-looking overwrite is
no different from a deleted one.

Chat deletion runs in a worker: right after an upgrade it also runs the
legacy move, which is not something the event loop can wait on. The
delete switch is offered for a chat in a project too, whose pre-move
files are in its own folder. A files value that is not a list is not a
wrapper, since the cards map over it. And a compiled cache in the
launch directory needs a file only the compiler writes before it goes
on sys.path.

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

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

* Reclaim a sandbox of empty folders, and keep a linked cache usable

A tool that only ran mkdir, or deleted what it wrote, left a directory
tree with no files in it, which the default delete refused and no chat
could reach again. Empty now means no files of the user's.

A built-in cache path that is a symlink had its target removed by the
clear and nothing put back, so the link dangled and the next compile
could not write through it.

The listing walks up to a couple of thousand entries and stats each
one, so it moved into a worker like the delete paths.

A persisted files array is only a wrapper if every entry has a name:
the rows read it, and one null took the whole chat view down.

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

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

* Trust nothing a sandbox can reach without checking it first

The marker is written and read without following a link, so one
replaced by a symlink no longer truncates whatever it points at, and
does not read as an owner either.

A cached path gets the checks a fresh resolve makes. A tool can rename
its own directory and leave a link to another chat's in its place, and
containment alone accepts that.

A recorded directory is only used when it could have been ours: inside
the root, not a link, named after this session, and not held by another
one. The ledger is under the studio home, which a relative path from a
sandbox still reaches, so an entry in it must not be able to name
anything this chat would not have been given anyway.

A recorded move target that names another root is stale: the override
can change between the interrupted move and the retry.

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

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

* Drop the record only once the directory is gone

A delete that keeps the files was clearing the note of where they are,
which after a tool has taken the marker is the only thing left saying
that directory is this session's. It goes now when the removal really
happened, and deletion checks ownership through the same trusted-record
test the resolver uses.

* Lock the ledger between processes, and stop running in borrowed folders

Two Studios can share a studio home, so the read-modify-write of the
ownership file takes a lock file as well as the in-process one, steals
one a dead process left behind, and proceeds rather than losing a write
if it cannot get it.

A _default or _invalid that was already sitting in a shared root is the
user's: a call with no session id used to run straight in it. We make
one of our own instead, claim it and remember it, so it is the same
folder on the next call rather than a new one each time.

A name kept on both sides of an interrupted move is deliberate, so the
move is finished with it. It was reading as failure, which left the
migration flag unset and rescanned the whole legacy tree on every first
tool call.

An id a path segment cannot carry now rides in a query parameter: ASGI
decodes %2F before it matches a route, so a slash in an API client's id
arrived as a different id and a different filename.

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

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

* Say in the ledger when a sandbox is in use, and stage what is arriving

A call in flight is noted in the ledger as well as in memory, so a
second Studio sharing this home does not rename a directory its tool is
working in. A delete that arrives then is left in the ledger and
carried out at the next startup, once nobody holds it. An entry older
than a tool call can run is ignored, so a process that died holding one
does not keep the folder for good.

A cross-filesystem move is assembled under a name nothing resolves and
renamed into place, per directory and per file, so a download card
opened during the migration cannot read a file that is half copied.

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

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

* Name it exactly, hold it per process, and finish what was staged

A recorded directory has to carry a name this session could have been
given, or a marker naming this session. A prefix test let a chat called
a name the folder of one called ab.

The busy note is one entry per process and keyed the way the in-memory
count is, so two Studios in one sandbox both hold it, the first to
finish does not clear the other's note, and two ids differing only in
case see each other's. A delete handed over by another Studio carries
the exact id and is drained when our own call ends, not only at the
next startup.

A move interrupted between the copy and the rename is put in place on
the next launch: the legacy entry is gone by then, so nothing else
would ever look at the staging directory again.

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

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

* Studio: renew sandbox leases, keep every queued delete, unblock the first tool call

Busy leases are renewed while a call is still running, so a call with no
timeout cannot have its folder deleted out from under it, and the read and
write of the busy entry now happen under one interprocess lock.

A recovered staging directory is claimed and recorded like an ordinary move,
and only directories a migration of ours actually staged are promoted.

Queued deletes accumulate per session instead of overwriting each other, a
recorded fallback directory survives losing its marker, clearing every chat
reports the ids it deleted, and the first tool call migrates only its own
folder while the rest of the tree moves in the background.

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

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

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

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

* Studio: never take a link for a marker, and keep a delete from racing a tool call

A cache marker reached through a symlink read as proof the directory was ours,
and the cleanup deletes such a directory outright, so a link planted in an
unowned unsloth_compiled_cache took the user's files with it. Ownership now
needs a real file.

A legacy session entry that is a symlink is left where it is: the move
preserves the link, and the marker written afterwards would land inside
whatever it points at, outside both roots.

A first tool call migrates only its own folder and starts the rest in the
background, rather than waiting out a copy of every session, and the two paths
take the same per-entry lock so one session is never moved twice.

Deleting a chat now cancels the generations still running for it. The in-flight
guard only covers calls already inside the executor, so a request that had not
got there yet could recreate the folder afterwards and write files no chat can
reach.

Session ids are hashed with surrogatepass, since an API client can send a lone
surrogate and a POSIX name decoded with surrogateescape carries them too.

File cards stream a download to the chosen path instead of buffering it into a
Blob and copying that across IPC, which a multi-gigabyte artifact would not
survive.

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

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

* Studio: let a delete run outside the tool lock, and offer files from every surface

Deleting a chat held the lock every tool start takes for the whole rmtree, so
removing one large sandbox stopped calls in every other chat. The tree is
renamed out of the way while locked and removed after.

A cross-filesystem move fills the destination as it goes, so a run killed part
way left a partial copy that the next launch read as a session the new root
already had, stranding the original. It is assembled under a name nothing
resolves to and renamed into place, and a failed attempt takes its own staging
tree with it.

Only the sidebar dialog offers to delete the files, and after a delete from
anywhere else the folder is unreachable. The route now reports which sandboxes
it kept and the shared delete path offers them, rather than leaving one behind
per chat.

The snapshot key carries a digest for files small enough to read: on a coarse
clock a rewrite of the same length inside one tick matched on mtime and size,
and the call reported no file at all.

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

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

* Studio: take no directory in a shared root that we did not make

The fallback name was claimed even when a directory of the user's already sat
there, so a chat ran inside their files and a delete with the switch would
have removed them. Both names now get the same test, and a fresh one is taken
rather than anything already present.

A marker reached through a symlink counted as ownership, since isfile follows
one, which made an unrelated directory deletable.

The path a chat with nothing of its own resolves to now lives outside every
sandbox root, so a folder the user keeps at that name is never listed or
served.

A request-path migration goes through the resolver like the whole-tree pass,
rather than stopping at the plain-name collision and leaving the chat with an
empty sandbox and its files at the old root.

Deleting a project now takes the sandboxes of the chats it held, which are
otherwise left with no record pointing at them.

Native file cards ask for an absolute loopback URL, which the native download
command requires: a relative one was rejected before the request was made.

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

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

* Studio: find fallback sandboxes by marker, repair clobbered markers, sweep interrupted deletes

- a fallback with a name nothing can recompute is found again by its marker,
  on a later launch, on a read and on a delete
- a marker tool code removed from a directory this run claimed is written
  again rather than the directory being read as somebody else's
- an interrupted detached delete is finished on the next launch, ours only
- a pre-upgrade chat whose id starts with the derived prefix is migrated from
  its literal legacy directory name
- clear-all reports the sandboxes it kept and offers to delete them, through
  the same helper the sidebar delete uses

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

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

* Studio: fix the Python tool image path test on the new sandbox route

The module import needed the .ts extension the node runner resolves, and an
id with a path separator now travels in the query rather than in the path.

* Studio: keep the file envelope ours, and land every sandbox move somewhere free

- the fallback suffix is encoded like the name it is appended to, so an id
  with a lone surrogate can still step aside
- a legacy move picks a free target when both derived names are the user's
- a read finds a marked random fallback, not only creation and deletion
- a chat started during clear-all is cancelled before its sandbox goes
- a call no longer reports another in-flight call's scratch script
- a marker line a program printed itself is broken before ours is appended

* Studio: report what a delete kept, and never delete the only copy of a move

- a removal deferred behind a running tool call is reported as kept
- a project delete uses the membership its transaction deleted, cancels the
  late ones, and returns the member sandboxes it preserved
- a staged legacy move that fails its final rename keeps the tree it already
  moved, marked, and puts it back at the legacy root when it can
- closing an incognito chat cleans up the sandbox its tool calls created
- a symlinked directory counts as a file of the user's

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

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

* Studio: bound the snapshot hashing, and claim only what one call wrote

- a snapshot stops hashing after 64 MiB and falls back to mtime and size
- a file written while two calls shared a workdir is claimed by neither
- a read serves the legacy directory while the background move is unfinished
- a markerless sandbox is only deletable by the id its name carries

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

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

* Studio: a shared workdir claims nothing, and one sweeper does the deleting

- a call that ran alongside another in the same workdir reports no files, with
  no dependence on filesystem timestamps
- a delete migrates its own session rather than the whole legacy tree
- the file envelope is read only from the tools that emit one
- a deferred removal runs after the global session lock is released
- a staged migration that could not be moved in is adopted by the resolver
- detached trees go to one sweeper thread instead of one per chat

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

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

* Studio: one migration lock per session, and a sentinel path that cannot escape

- a chat's legacy copy no longer blocks another chat's first tool call
- the empty-sandbox sentinel derives its name, so an absolute session id
  cannot make it resolve to a directory of the system's
- a legacy copy whose destination is already this chat's is left where it is
- an interrupted delete at our own root is swept without needing its marker

* Studio: derive every fallback name, and delete the project the transaction did

- fallback names are derived and recomputable, so a big shared root can no
  longer hide a chat's folder from the scan
- the startup migration picks a free target like the request path does
- a staged move is marked before the rename, not after
- a delete uses the directory this process created when its marker is gone
- the project delete acts only on the membership its transaction deleted
- the client reads the file envelope only from the tools that emit one

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

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

* Studio: adopt a sandbox by name, and keep what the old shared bucket holds

- a directory is only adopted under a name derived from the chat's own id, so
  a marker a tool rewrote cannot hand one chat another chat's files
- the pre-upgrade _invalid bucket is read where it is and never moved
- a user's own .unsloth_sandbox file is preserved by the migration
- a project workspace is removed after its chats' tool calls are stopped
- a sandbox a surviving fork still shows cards for is kept
- the second research path names its tool when stripping

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

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

* Studio: wait for a project's tools, and match a sandbox reference exactly

- the workspace delete waits for the member chats' tool calls to unwind
- a reference is a decoded sessionId value, not a substring of the message
- a workspace a surviving fork still shows cards for is kept
- the project delete cancels the research runs its transaction removed, by id

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

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

* Studio: hold a session closed while its sandbox goes, and stop the right tools

- a start for the session being removed waits, so no call is handed a folder
  the removal is about to rename away
- the project wait covers project-<id>, which is what its tool calls run as
- a kept project workspace still resolves after the row is gone
- a marker-named file is preserved unless it is the exact marker being written
- clear-all cancels the research runs its transaction removed, by id
- the supervisor is signalled even when the run row has already cascaded

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

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

* Studio: remember a kept workspace, and never delete one still in use

- a workspace whose idle wait timed out is kept, not removed under a live call
- a kept workspace is written down, so a custom location still resolves and
  the next delete collects it once nothing points at it
- a marker a tool rewrote with another id is corrected, its file preserved
- the empty-sandbox scan runs with the global lock released
- a compiled-cache directory needs a generated file, not just the name

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

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

* Studio: record a kept workspace either way, and finish the delete it promised

- the path is written down whether or not files were to be deleted, since the
  row that knew it is gone in both cases
- the record says whether the user asked for the files, and only those are
  ever collected
- a delete held up by a running tool call finishes when that call ends, and
  the startup sweep picks up one a kill interrupted

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

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

* Studio: hide only the sandbox's own bookkeeping, and keep the records writable

- a pending workspace delete is collected after any deletion, not only one
  that asked for files
- a nested file named like the marker is an ordinary file in both walks
- the orphan records live under the studio home, which is ours to write
- an inline image below a subdirectory keeps its path separators

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

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

* Studio: keep the old shared bucket to the ids that shared it

- only an id the previous code could not use as a name reads _invalid, so an
  ordinary chat is never served another chat's files
- a markerless directory is this chat's only when its name says so, on the
  read path as well as the delete path
- a workspace delete that failed stays pending instead of being forgotten
- a deferred workspace delete removes the whole project workspace, as the
  immediate one does

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

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

* Studio: keep a retry record, fail safe on a locked database, claim the default sandbox

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

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

* Studio: tighten the sandbox comments

* Studio: serialise a legacy read with its move, retry a detached delete, keep an overwritten marker

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

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

* Studio: reserve the fallback names, recheck a shared call, and keep a recreated chat's files

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

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

* Studio: validate every recorded delete, pin the download to the file it checked, and reclaim a fork's source

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

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

* Studio: a linked root is the user's, and a recreated chat or project keeps its files

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

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

* Studio: answer the download probe, and key kept-folder records by kind and id

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

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

* Studio: keep a directory this run claimed, list the root once, and unwrap only our own tools

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

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

* Studio: keep the sandbox wrapper check a type guard

* Studio: recover on startup, keep a reused project id from stranding the old workspace

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

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

* Studio: final pass over the sandbox comments

* Studio: skip the snapshot digest where mtime already separates the writes

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-10 01:11:46 -07:00

25 lines
1,017 B
TypeScript

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import assert from "node:assert/strict";
import test from "node:test";
import { pythonToolImagePath } from "../src/components/assistant-ui/python-tool-image-path.ts";
test("escapes Python tool image path segments", () => {
// An id with a path separator now travels in the query rather than in the
// path: an encoded slash is rejected or decoded by proxies before the route
// sees it, so it did not survive the round trip.
assert.equal(
pythonToolImagePath("session/id", "loss curve #1.png"),
"/api/inference/sandbox/_/loss%20curve%20%231.png?session=session%2Fid",
);
});
test("keeps authentication out of the Python tool image URL", () => {
const path = pythonToolImagePath("session", "plot.png");
assert.equal(path, "/api/inference/sandbox/session/plot.png");
assert.ok(!path.includes("token"));
assert.ok(!path.startsWith("http"));
});