Filesystem backend for Nous.Spill.
Layout
<root>/session-<sha256_hex(owner)>/<random>-<safe_name>root comes from opts[:root] and defaults to
Path.join(System.tmp_dir!(), "nous-spill").
The owner is hashed, never used raw: a session id can contain a path
separator, a .., or be derived from something a user controls, and any of
those would let the owner steer the write out of root. A hex digest cannot.
The <random> component means two saves with the same owner and name never
collide, so a spilled result is never silently overwritten by a later one.
Permissions
Spilled content is whatever a tool just read or produced — file contents,
command output, search hits. On a shared host that is nobody else's business,
so the session directory is 0700 and each file is 0600.
Both need an explicit File.chmod/2. File.mkdir_p/1 takes no mode
argument, so a fresh directory is created at 0777 &&& ~umask, which on a
typical 022 umask leaves it group- and world-readable. File.open/2 is the
same story for the file. The file is chmodded before its bytes are written,
so the content never exists on disk at the laxer inherited mode.
Exclusive create
Files are opened [:write, :exclusive, :binary]. Exclusive create is what
stops a planted symlink from redirecting the write: O_EXCL fails outright if
the path already exists — including as a symlink — instead of following it and
truncating whatever it points at. A pre-created <random>-<safe_name> symlink
is therefore an error, not an arbitrary-file overwrite.
Retention
Files persist until the operator deletes them. See the retention section of
Nous.Spill for why there is deliberately no reaper here.
Summary
Functions
Read a spilled file back, byte for byte.
Tell the model how to open this locator: the concrete path, and the tool.
Write content under <root>/session-<sha256_hex(owner)>/<random>-<safe_name>.
Functions
@spec fetch(Nous.Spill.Locator.t()) :: {:ok, binary()} | {:error, term()}
Read a spilled file back, byte for byte.
Returns File.read/1's error tuples unchanged — {:error, :enoent} for a
file the operator has since deleted, {:error, :eacces} for one it can no
longer read.
@spec retrieval_hint(Nous.Spill.Locator.t()) :: String.t()
Tell the model how to open this locator: the concrete path, and the tool.
The hint names file_read, which is fenced to the workspace root by
Nous.Tools.PathGuard. A spill :root outside that workspace therefore
produces a path the model cannot read, and the hint does not pretend
otherwise — it states where the content is and which tool opens it, and leaves
reachability to the operator who chose the root. Keeping the spill root inside
the workspace is what makes the hint actionable.
@spec save_text(Nous.Spill.attrs()) :: {:ok, Nous.Spill.Locator.t()} | {:error, term()}
Write content under <root>/session-<sha256_hex(owner)>/<random>-<safe_name>.
attrs is the Nous.Spill.attrs/0 map plus the :opts keyword list
Nous.Spill.save_text/2 injects from configuration. Only opts[:root] is
read.
Returns {:error, term} for every failure — a missing root, an unwritable
directory, a full disk — and never leaves a partial file behind: if the write
or the chmod fails after the file was created, the file is removed.