c59bea9eda
* feat(files): let the file panel navigate anywhere the session can reach
The web UI's file panel was pinned to the session's starting directory.
That confinement was a UI limitation, not a security boundary: every
native coding agent ships `sandbox: {type: none}`, so the session's own
shell already reads and writes anything the runner can. The panel simply
refused to display it — `_validate_path` rejected absolute paths outright,
and there was no way to name a location outside the workspace at all.
Naming a location: a leading `/` means absolute, on both `filesystem` and
`search`. Relative paths keep the historical contract, traversal guard
untouched. Only the first slash is percent-encoded on the wire, since a
literal `//` is what proxies collapse.
Authorization: `reachable_roots()` enumerates cwd plus the declared
sandbox grants, and `_assert_within_reach` now consumes that same list, so
what is enforced and what is advertised cannot drift. Absolute paths are
accepted only when the server vouches for the caller, which it does after
checking LEVEL_EDIT — the level that already grants shell. A confined
agent gets no widening, and a read grant still never confers write.
Search follows the tree, with a scan budget modeled on
`scan_cwd_mask_entries`: a query matching nothing never fills the result
cap, so a walk from a large directory needs its own deterministic bound.
Dependency and cache dirs are walked last so the budget covers real
content first.
UX: the working-folder path becomes clickable and opens the same
directory browser the new-session flow uses, which brings its typed path,
Up / Home and show-hidden along. A workspace-root button returns you in
one click. Because navigating a viewer cannot move the agent's working
directory, the composer tells the agent where the user is looking.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): authorize the host-fallback root lazily; cover browsing in e2e_ui
Absolute browsing was refused on a runner-only session. The read routes
resolved the host-fallback workspace eagerly, and that resolution needs a
recorded `conversation.workspace` — which a session with no bound host does
not have. A live runner authorizes the path itself against its own resolved
policy, so the resolution only matters when the host fallback is actually
taken; deferring it until then fixes those sessions.
Adds the e2e_ui coverage that caught it: bind a session to a stubbed host,
open the working-folder path, pick a directory outside the workspace and
assert both the tree and search re-root there. Only the host binding is
faked — the reach, the authorization and the listing are the real server,
runner and filesystem.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): address review — scoped-search stat, read-grant writes, tight budget
Three defects Polly's review found, each with a regression test that fails
without its fix:
- Scoped search statted the result path, which is relative to the search
base, while the helper's cwd is the workspace root. An absolute or
subdirectory search therefore reported null metadata, or a same-named
workspace file's size and mtime. Stat the full path instead.
- `_within_grants` ignored the access being requested, so in an unconfined
environment a write landing inside a READ grant was routed through the
guarded helper, which denies it — refusing a write the environment's own
shell can already make. The routing decision now considers `need_write`.
- The search scan budget was checked once per directory, so a single very
large directory could overshoot it before `truncated` tripped. Counted
per entry now, in both the runner script and the host-side reader.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): check containment on every browse return; annotate CodeQL alerts
`resolve_browse_target` had one branch that returned the resolved path with
no containment check at all — the unconfined case. State that reach as what
it actually is, a grant rooted at the filesystem root, so every return goes
through the same check. Behaviour is unchanged; the shape is now auditable
without reading the branch order.
The three CodeQL `py/path-injection` alerts are annotated rather than
designed around. The rule does not recognize this codebase's containment
idiom: it already fires, and is already open on main, for this module's
workspace-confined `_resolve` — which normalizes, rejects absolute paths and
`..`, resolves, and then re-checks the resolved path with `relative_to` and
raises. Each annotation records why the flow is bounded at that site.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* chore(api): regenerate openapi.json for the scoped-search route
The new `/search/{path}` route left `openapi.json` out of sync with
`scripts/dump_openapi.py`, which `test_openapi_drift` guards. Regenerated;
the diff is that one added path and nothing else.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): put the CodeQL suppression markers where CodeQL reads them
A suppression comment is only honoured on the flagged line or the line
immediately above it. The markers were buried mid-paragraph three or four
lines up, so they would not have applied. Justification prose first, bare
marker directly above the expression.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(runner): allowlist the session id before it becomes a path component
`session_id` arrives from the URL and is used as a directory name under the
runner workspace, so it was already sanitized — but with a denylist that
enumerated `/` and `..` and therefore missed a backslash, which is a real
separator on a Windows host, along with NUL and control characters.
Switch to an allowlist. Note the obvious allowlist is not sufficient on its
own: `[^A-Za-z0-9._-]` permits `.`, so it leaves `..` untouched and would
REINTRODUCE the traversal the old denylist did stop. Dots are handled
explicitly, so a component that is empty or all dots can never be emitted.
Tests pin both the component and the property callers depend on (the joined
workspace path stays under the runner root). They fail against the old
denylist (7 cases) and against the plain allowlist (3 cases).
This is the sanitizer CodeQL's `py/path-injection` alerts trace back
through; it could not see the denylist inside the callee.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(paths): make the containment checks the ones CodeQL can verify
Guessing at this scanner twice was wrong, so I ran it: downloaded the CodeQL
bundle, built a database from this repo, and read the query's own definitions.
`py/path-injection` is a two-state machine. A tainted path starts
`NotNormalized`; only `os.path.normpath` / `abspath` / `realpath` move it to
`NormalizedUnchecked`; and the ONLY thing that then clears it is
`str.startswith` used as a guard (`StartswithCall` is the single
`SafeAccessCheck::Range` in the whole Python model). The query file states
outright that checks are "ineffective in the NotNormalized state".
Two consequences the code was on the wrong side of:
- `Path.resolve()` is a *sink* (`PathlibFileAccess`) but NOT a normalization —
pathlib is explicitly unmodeled there ("TODO: Handle pathlib"). So resolving
through pathlib touches the path while it is still unchecked.
- `relative_to` in a try/except is not a recognized check, so the guard that
was there could never clear anything. Neither could the suppression comments
or the sanitizer allowlist — and Copilot Autofix's suggested regex would not
have either, besides reintroducing the `..` traversal it fails to strip.
So containment now goes through one shared primitive, `contained_realpath`:
realpath first, then a prefix test, then hand back the result. Both sides of
that test carry a trailing separator, which is what stops a boundary at
`/data` from admitting `/database` while still admitting `/data` itself — the
separator is stripped again before returning so callers get an ordinary path.
`ReachableRoot.prefix` is the one definition of a grant's boundary, shared by
`contains()` and by the callers that inline the comparison.
Verified against the real query rather than asserted: origin/main reports 57
path-injection alerts, this branch reported 61 before (+4), and 53 after (-4).
The four new ones are gone, and so are four that predate the PR — the session
workspace join and the workspace-relative resolve now assert containment at
runtime instead of relying on the caller having sanitized the input.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(paths): pin the symlink-loop case the containment rewrite changed
A differential over 20k generated paths found exactly one behavioural
difference between the old pathlib containment and the new one: a symlink
cycle inside the boundary. `Path.resolve()` raised ELOOP so the check
refused it; `realpath` returns it unresolved so containment admits it.
Nothing escapes -- the cycle stays under the boundary and every syscall
through it fails with ELOOP, so the refusal moves from the check to the
read. Pinned so it is not later mistaken for a hole and 'fixed'.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): require session ownership to browse outside the workspace
Gating absolute paths at LEVEL_EDIT made this route a weaker parallel path
to `/v1/hosts/{id}/filesystem` — the endpoint behind the workspace picker,
which is owner-scoped ("Authorizes (owner check)… don't leak existence to
non-owners"). An EDIT collaborator on a shared session could not browse the
host through that endpoint, but could read the very same files through this
one. That is a bypass, not just an inconsistency.
Absolute paths now require LEVEL_OWNER, on reads, search, and every mutation.
Workspace-relative paths keep LEVEL_EDIT: the workspace is the session's
shared context, so a collaborator who can edit the session can edit it. Past
the workspace is the owner's own machine.
This is not yet a hard boundary — the shell proxy is still LEVEL_EDIT and
unconfined, so an edit collaborator can read the same files by running a
command. That gap predates this branch and is pinned by the strict-xfail
matrix in test_filesystem_path_isolation_e2e.py. What changes here is that
the file panel no longer hands it to them casually, and this route is no
longer weaker than the host endpoint it parallels.
Tests live with the shell gate they mirror rather than in a new file; its
docstring now covers both. Verified they bite: reverting the gate fails
exactly the two edit-collaborator denials, while the read-only case passes
either way (READ is below EDIT regardless).
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* refactor(files): drop browse_outside_workspace; ownership is the whole rule
The flag was a second representation of a decision the server already makes.
At every call site it was set exactly when the path started with "/", so it
carried no information the runner could not read off the path itself — a
boolean meaning "trust me, I checked", threaded through seven runner routes.
With absolute paths gated on session ownership, the rule states itself: the
owner may browse outside the workspace, nobody else may. One place decides
it (`_browse_level`), and the split between the two processes is now clean:
server — decides WHO may ask. Absolute path => LEVEL_OWNER, for reads,
search and every mutation. Relative keeps the usual bar.
runner — decides WHAT the environment may reach. Absolute paths are
admitted only by a declared grant or an unconfined policy. It
cannot see the caller, so it no longer pretends to.
The runner keeps a real check of its own: a CONFINED environment still
refuses an out-of-grant absolute path regardless of who is asking. What it
loses is the redundant vouch, so `test_absolute_path_rejected` no longer
holds for the unconfined fixture it used. Rather than delete the coverage,
it is split in two — a confined environment refuses (the runner's own
check), an unconfined one serves (deferring to the server) — with both
sides pointing at where the other half of the guarantee lives.
Coverage for the property itself is the point, so the permission gate suite
now runs the matrix: owner and admin allowed, edit and read-only denied,
across read / search / delete, plus unauthenticated, plus controls proving
the bar applies to absolute paths ONLY and shared sessions still work.
Reverting the gate fails eight of them.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): gate any absolute path shape at owner, not just POSIX
The owner gate tested `client_path.startswith("/")`, which is the wire
form this API defines — but the gate decides IDENTITY, and a
`C:\\Users\\...` or UNC path is absolute too. Those were treated as
workspace-relative and admitted at the collaborator level, stopped only by
the runner refusing them further down. An identity decision should not rely
on a later layer catching it.
`ntpath.isabs` is true for a POSIX leading slash as well as Windows drive
and UNC roots, so it fails closed on every absolute shape while leaving
workspace-relative paths untouched.
The wire-format decision stays `startswith("/")`: encoding the runner URL
is a URL question, and URLs use `/` everywhere. The two predicates can
disagree only for a Windows-shaped path, where the result is a stricter gate
plus a runner-side refusal — closed on both counts. Separately: the
containment primitive keeps `os.sep`, which is right there because it
compares real filesystem paths from `os.path.realpath`, not URL segments.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* test(e2e): prove only the owner can browse outside a shared workspace
The route-level matrix stubs the permission store, so nothing proved the
wiring between a genuinely shared session and the gate. This drives it end
to end: one live server, a real session, a real PUT /permissions grant at
EDIT (the strongest level short of ownership), and two browser contexts
carrying different identities.
The owner opens the files panel, navigates outside the workspace and sees a
file that exists ONLY there. Bob, granted the same session, reaches the same
directory and the panel names the reason instead -- 'needs owner permission
on session ...' -- and the same request over his own authenticated context
is 403.
The refusal is asserted as a POSITIVE signal on purpose. The obvious
version, 'owner-only.txt is not present', is satisfied the instant the page
loads and passes with the gate removed entirely; I confirmed that by
reverting the gate and watching it pass before the API check caught it.
Reverting the gate now fails at the UI assertion, where an e2e test should
fail. Bob's navigation is also asserted to have happened, so the absence is
about the fetch being refused rather than the click silently not landing.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): keep the browse affordance when the agent is asleep
Navigating outside the workspace silently stopped working once a session's
runner went to sleep. The server synthesizes the environment resource itself
in that state, and the synthesis emitted only `metadata.root` -- no
`reachable`. The panel gates its navigation control on that field, so it read
"nowhere else to go" and fell back to the plain, unclickable label.
Nothing was wrong below it: with the runner offline I confirmed the
host-served path already lists an absolute directory (200) and runs an
absolute-scoped search (200), because `_authorize_absolute_browse` authorizes
the target server-side before the host is handed a root. Only the
advertisement was missing, and the advertisement is what the UI gates on.
The payload shape now has one definition, `sandbox.reach_payload`, used by
both producers -- the runner while the agent is awake, the server while it
sleeps -- so a browser cannot be told one thing by one and something else by
the other. That is the same enforce-and-advertise-from-one-source rule
`reachable_roots` already follows.
The regression test asserts the whole payload rather than the field's
presence, since a synthesis that advertised a *different* reach from the
runner's would be its own bug. It fails with `KeyError: 'reachable'` against
the previous synthesis.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): don't offer the browse control to a non-owner
A shared collaborator could click the working-folder path, and then nothing
loaded. The panel gated the control on `metadata.reachable`, which describes
what the ENVIRONMENT can reach and is byte-identical for every viewer of a
session -- so it cannot answer "may THIS person go there". Confirmed against a
live shared session: owner and collaborator receive the same `reachable`
payload while their permission levels are 4 and 2.
Two things then went wrong for the collaborator: the absolute browse is
refused 403 by the owner gate, and the picker itself reads the owner-scoped
`/v1/hosts/{id}/filesystem` endpoint, which also 403s -- so the control opened
onto an error. Offering an action that is guaranteed to fail is worse than not
offering it.
The panel now also consults the viewer, via the existing `isOwnerLevel`
helper that the workspace rail already uses to decide `readOnly`. It is read
off the session snapshot the panel already fetches for `hostId`, so no prop
threading and no extra request. `isOwnerLevel(null)` stays permissive, which
is what keeps browsing available to the only user of a single-user server.
This is presentation, not the boundary: the server's LEVEL_OWNER gate is
unchanged and remains what actually refuses the request. If the two ever
disagree the worst case is a control that 403s -- exactly today's behaviour --
so the e2e asserts BOTH halves: the collaborator is not offered the control,
and the same request over their own authenticated context is still 403. That
second assertion is what fails if the server gate is ever removed.
Reverting the client gate fails the e2e, and the unit tests cover owner,
collaborator, and the unknown-level single-user case.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* style: apply ruff formatting to the merged test file
The merge landed my offline-synthesis block next to main's gzip-route block;
ruff format wants a blank-line adjustment at the seam. The Databricks hook
skips pre-commit during a merge commit, so this was caught by running the
hooks explicitly afterwards rather than by the commit itself.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* feat(files): copy-path buttons; stop injecting the browsed dir into the turn
Removes the browse-location marker the composer prepended to every message
while the panel was pointed away from the workspace. Navigating a viewer is
not something the user asked the agent to act on, and writing it into the
turn made an ambient UI detail part of the conversation the agent reasons
over — on EVERY message, not just file-related ones. Deleted outright rather
than made conditional: `browsingMarkerFor`, the composer preamble, the
`BROWSING_RE` bubble stripper, and the `browseLocation` store field. Nothing
persisted carries the marker (it only ever existed on this branch), so the
stripper had nothing left to strip. The panel keeps its own local browse
state — that is the navigation feature, untouched.
Adds a copy-path button in three places, all one component:
- every file row in Changed and All (hover-reveal, beside the download
button, mirroring FileDownloadButton's placement and feedback pattern)
- the working-folder header, beside the hidden-files eye (always visible),
copying the ABSOLUTE path of wherever the panel is currently pointed
Feedback is transient and in place — a check for two seconds, or a red icon
with "Copy failed" for three. No toast: with a hundred-plus of these on
screen, the confirmation belongs on the row the user clicked.
Two details worth knowing:
The accessible name carries the BASENAME while the clipboard gets the FULL
path. My first cut put the whole path in `aria-label`, which broke four
existing tests: a name like "Copy path: src/app.ts" collides with the
`/src\//i` queries used to find folder-toggle buttons. It is also noise for
a screen reader on every row. FileDownloadButton already uses the basename;
matching it fixes both. A test pins the split, since inverting it (copying
the basename) would be a silent, plausible-looking bug.
I also wrote a test asserting the click does not open the file, then found
it passed with `stopPropagation` removed — the button is a SIBLING of the
row's clickable element, not a child, so nothing propagates. Deleted the
vacuous test and corrected the comment to say the guard is defensive
(FolderTree's directory rows ARE buttons, so a future placement inside one
would need it).
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): align the file rows' trailing controls; copy paths from folders too
Two fixes to the file panel's row layout.
**Alignment.** The trailing controls sat at a different x on every row —
measured on a live tree: 11 distinct positions spanning ~16px. The cause is
the metadata column being content-sized: `formatBytes` ranges from "985 B"
to "463 KB", and in the changed list a diffstat ranges from "+7 −1" to
"+1204 −318". Everything to the LEFT of that variable text — the copy
button, the download button, the git status marker — inherits its jitter.
Pre-existing, but a second icon in the cluster made it obvious.
The metadata column is now a fixed width (`ROW_META_SLOT_CLASS`, exported
from fileStatusUtils so the two row components cannot drift apart) and is
rendered ALWAYS, even when empty — directories carry no size, and omitting
the slot for them kept folders off the same grid as files. Measured after:
one x for every row in the tree, folders and files alike.
**Folders had no copy button.** Not an oversight in placement: the whole
directory row WAS a `<button>` (the expand toggle), so a copy control could
not be nested inside it — a button inside a button is invalid HTML and React
will not render it usefully. The row is now a wrapper div with the toggle as
an inner `flex-1` button and the copy control as its sibling, mirroring how
file rows were already built. The toggle still spans everything up to the
copy button, so the clickable area is effectively unchanged.
That restructure moved the row indent from the button to the wrapper, which
the existing VS-Code-alignment test caught. Updated it to compare row div to
row div — like-for-like, where it previously compared a folder BUTTON against
a file DIV, an asymmetry that only existed because folders were buttons.
Both new tests were verified to fail without their fix: dropping the folder
copy button fails two, and making the slot content-sized again fails the
alignment one.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): pair the copy button with the download button
The copy button sat before the metadata column and the download button
after it, so the two controls were separated by the whole ~56px slot
instead of reading as one action pair.
Both now live inside that column: metadata at rest, [copy][download]
adjacent on hover. Measured on a live tree — 2px apart, and still one x
for every row.
Rows without a download (a folder, a deleted file) render an empty spacer
in its place rather than letting the copy button slide right into the
freed space; `ROW_ACTION_SIZE_CLASS` documents that footprint next to the
slot width it pairs with. The changed list gets the same treatment so both
tabs read identically.
The alignment test moved with the markup: it previously asserted the copy
button's sibling WAS the slot, which stopped being true once copy moved
inside. It now pins what actually matters — the copy button sits in the
fixed column AND is immediately followed by the download button or its
reserved footprint. Verified it fails when anything is inserted between
the two.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): put the copy button to the right of the download button
Swaps the pair's order in all three row types. Measured live: download at
x=1446, copy at 1466, 2px apart, one x for every row.
The alignment test asserted the copy button's NEXT sibling was its pair, so
it flips to the previous sibling — copy is now the rightmost control.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): line the folder dirty-dot up with the file status letter
The two git-status markers ended a tree row's name button but were each
sized to their own content: the dot centred in a fixed 22px box, the A/M/D
letter a variable-width badge centred on itself. Measured live, that put
them 4px apart -- close enough to read as a wobble down the tree rather
than a deliberate column.
Both now centre in the same slot (ROW_STATUS_SLOT_CLASS, exported alongside
the other row-column widths so they can't drift apart). Measured after: dot
and letter both at x=1411.
The existing dot test asserted only the dot's own width, and its comment
claimed the dot aligned with the download column -- which stopped being
true when the rows were restructured. It now checks the shared slot from
both sides, and fails if the letter is unwrapped again.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* style: collapse the status-slot cn() call to one line
Prettier keeps the call on a single line -- it fits inside the 100-column
limit. Caught by CI's `prettier --check .`, which failed both the
pre-commit job and the web test job.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* fix(files): drop the onFlatViewChange prop the merge removed
main took scope out of the panel (it is a rail tab now), so FilesPanelProps
no longer declares onFlatViewChange. One render in the test file still
passed it -- the last reference anywhere in the tree -- which failed the
typecheck. The file's shared renderPanel helper already omits it.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
* feat(files): double-click a folder to make it the working folder
Finder's contract: a single click still expands the row in place, a double
click re-roots the panel onto that folder. The header follows, and the tree
redraws at the new root.
Navigating INSIDE the workspace now goes out as a workspace-RELATIVE
location. That is not cosmetic: the server authorizes an absolute location
at owner level -- it can name any path on the host -- so sending a
subfolder's absolute path would 403 every collaborator opening a folder
already listed in front of them. Only genuinely-outside paths stay
absolute, where the owner gate belongs.
Choosing the wire form on authorization grounds means the two forms must
mean the same thing, and they did not: a relative target is echoed back as
a prefix on every entry ("reports" -> "reports/summary.md") while an
absolute one is not. Un-stripped, the browsed folder rendered as an extra
level inside its own tree. Both forms now normalize to paths relative to
the browsed location, which also fixes lazily-expanded children losing
their parent prefix under an absolute location -- expanding one level
deeper had been requesting the wrong path.
Two follow-on corrections the navigation exposed:
- The expanded-paths cache is keyed by browsed location as well as
conversation. Node paths are relative to the root, so a set captured at
one root describes different directories at another; carrying it across
a re-root collapsed the new tree and could expand an unrelated
same-named folder.
- Files opened from the tree get the location re-attached. Tree paths are
relative to where the tree is rooted while the viewer resolves against
the workspace root, so opening a file after navigating into a folder
looked in the wrong place and hung on "Loading...".
Verified live against a running server, confined and unconfined: two
levels deep, lazy expansion at the new root, files opening, and the picker
flow to an outside directory all unchanged.
Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>
---------
Signed-off-by: dbczumar <corey.zumar@databricks.com>
470 lines
19 KiB
Python
470 lines
19 KiB
Python
"""Process-agnostic, read-only workspace filesystem reader.
|
|
|
|
The runner serves the web UI's file panel (directory browse, changed
|
|
files, diffs, search, file content) by reading its sandboxed
|
|
workspace. When the runner process dies but the host that holds the
|
|
workspace on disk is still connected, the host serves the same panel by
|
|
running this module against the workspace directory directly.
|
|
|
|
This reader is deliberately *read-only* and *sandbox-free*: it never
|
|
writes, never runs a shell, and confines every path to the workspace
|
|
root. It reuses the runner's pure helpers (glob translation, path
|
|
validation, pagination, the git/edit change registry) so the JSON it
|
|
returns is byte-identical to the runner's filesystem endpoints — the
|
|
server proxy layer and the frontend cannot tell which side answered.
|
|
|
|
The returned dicts match, one-to-one, the runner endpoints in
|
|
``omnigent/runner/app.py``:
|
|
|
|
- :meth:`WorkspaceReader.list_or_read` → ``_fs_list_or_read``
|
|
- :meth:`WorkspaceReader.changes` → ``list_filesystem_changes``
|
|
- :meth:`WorkspaceReader.diff` → ``read_environment_file_diff``
|
|
- :meth:`WorkspaceReader.search` → ``search_environment_files``
|
|
|
|
Change-tracking caveat: in a **git** workspace the changed-files list
|
|
and diff baselines come from ``git status`` / ``git show`` and are fully
|
|
reconstructable from disk, so the host serves them exactly like the
|
|
runner. In a **non-git** workspace the runner tracks changes from the
|
|
live agent's tool calls (in-memory), which the host does not have — so
|
|
the host returns an empty changed-files list there. Directory browse,
|
|
search, and file content work identically in both modes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import mimetypes
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
from typing import TypeAlias, cast
|
|
|
|
from omnigent.entities.environment_filesystem import InvalidPath
|
|
from omnigent.entities.pagination import paginate_in_memory
|
|
from omnigent.inner._cwd_scan import _DEFAULT_DEPRIORITIZED_DIRS
|
|
from omnigent.inner.os_env import _DEFAULT_READ_LIMIT
|
|
from omnigent.runner.environment_filesystem import (
|
|
_SEARCH_SCAN_BUDGET,
|
|
_glob_to_regex,
|
|
_validate_path,
|
|
split_glob_list,
|
|
)
|
|
from omnigent.runtime.filesystem_registry import (
|
|
GitStatusUnavailable,
|
|
create_filesystem_registry,
|
|
)
|
|
|
|
# Match the runner's caps so a host-served read is truncated identically.
|
|
_MAX_READ_BYTES = 10 * 1024 * 1024 # 10 MiB
|
|
|
|
_WorkspacePayload: TypeAlias = dict[str, object]
|
|
|
|
|
|
class WorkspaceReaderError(Exception):
|
|
"""A workspace read failed with a specific HTTP-mappable outcome.
|
|
|
|
Carries a ``status`` code and an error ``code``/``message`` so the
|
|
host handler can echo the same shape the runner endpoints return
|
|
(404 not-found, 400 invalid-path, 500 git-status-failed).
|
|
|
|
:param status: HTTP status the runner would have returned.
|
|
:param code: Machine-readable error code, e.g. ``"not_found"``.
|
|
:param message: Human-readable detail.
|
|
"""
|
|
|
|
def __init__(self, status: int, code: str, message: str) -> None:
|
|
super().__init__(message)
|
|
self.status = status
|
|
self.code = code
|
|
self.message = message
|
|
|
|
|
|
class WorkspaceReader:
|
|
"""Read-only view of a workspace directory, confined to its root.
|
|
|
|
:param root: Absolute path to the workspace directory on disk, e.g.
|
|
``Path("/Users/alice/project")``.
|
|
"""
|
|
|
|
def __init__(self, root: Path) -> None:
|
|
self._root = Path(root).resolve()
|
|
# The change registry (git or edit-tracking) is chosen the same
|
|
# way the runner chooses it, so git workspaces get git-status
|
|
# semantics and everything else degrades to an empty list.
|
|
self._registry = create_filesystem_registry(self._root)
|
|
self._registry.start()
|
|
|
|
# ── Path confinement ──────────────────────────────────────────
|
|
|
|
def _resolve(self, path: str) -> Path:
|
|
"""Resolve a relative path to an absolute path under the root.
|
|
|
|
:param path: Relative path within the workspace (``""`` = root).
|
|
:returns: Resolved absolute path guaranteed under the root.
|
|
:raises WorkspaceReaderError: 400 when the path escapes the root
|
|
or is otherwise invalid.
|
|
"""
|
|
try:
|
|
validated = _validate_path(path) if path else ""
|
|
except InvalidPath as exc:
|
|
raise WorkspaceReaderError(400, "invalid_path", str(exc)) from exc
|
|
if not validated:
|
|
return self._root
|
|
full = (self._root / validated).resolve()
|
|
try:
|
|
full.relative_to(self._root)
|
|
except ValueError as exc:
|
|
raise WorkspaceReaderError(
|
|
400, "invalid_path", f"Path {path!r} escapes the workspace root"
|
|
) from exc
|
|
return full
|
|
|
|
# ── Directory listing / file content ──────────────────────────
|
|
|
|
def list_or_read(
|
|
self,
|
|
path: str,
|
|
*,
|
|
limit: int = 20,
|
|
after: str | None = None,
|
|
before: str | None = None,
|
|
order: str = "desc",
|
|
) -> _WorkspacePayload:
|
|
"""List a directory or read a file, mirroring ``_fs_list_or_read``.
|
|
|
|
:param path: Relative path (``""`` for the workspace root).
|
|
:param limit: Max entries for a directory listing.
|
|
:param after: Forward-pagination cursor entry id.
|
|
:param before: Backward-pagination cursor entry id.
|
|
:param order: Sort order, ``"asc"`` or ``"desc"``.
|
|
:returns: A directory-listing dict or a file-content dict.
|
|
:raises WorkspaceReaderError: On invalid path or missing file.
|
|
"""
|
|
resolved = self._resolve(path)
|
|
if resolved.is_dir():
|
|
return self._list_dir(path, resolved, limit, after, before, order)
|
|
return self._read_file(path, resolved)
|
|
|
|
def _list_dir(
|
|
self,
|
|
rel: str,
|
|
resolved: Path,
|
|
limit: int,
|
|
after: str | None,
|
|
before: str | None,
|
|
order: str,
|
|
) -> _WorkspacePayload:
|
|
"""Build the directory-listing payload for a resolved directory.
|
|
|
|
Classifies entries by target type (follows symlinks) and skips
|
|
per-entry ``OSError`` (e.g. a broken symlink) so one bad entry
|
|
does not fail the listing — matching the runner's ``list_dir``.
|
|
"""
|
|
validated = _validate_path(rel) if rel else ""
|
|
entries: list[_WorkspacePayload] = []
|
|
try:
|
|
names = sorted(os.listdir(resolved))
|
|
except OSError as exc:
|
|
raise WorkspaceReaderError(
|
|
404, "not_found", f"Directory {rel!r} not found or not accessible"
|
|
) from exc
|
|
for name in names:
|
|
full = resolved / name
|
|
child_rel = os.path.join(validated, name) if validated else name
|
|
try:
|
|
st = full.stat() # follows symlinks, like the runner
|
|
is_dir = full.is_dir()
|
|
entry_type = "directory" if is_dir else "file"
|
|
size = st.st_size if entry_type == "file" else None
|
|
mtime = int(st.st_mtime)
|
|
except OSError:
|
|
# Broken symlink (target gone): fall back to lstat and list it
|
|
# as a file with no size, matching the runner's list_dir rather
|
|
# than dropping the entry.
|
|
try:
|
|
ls = full.lstat()
|
|
except OSError:
|
|
continue
|
|
entry_type = "file"
|
|
size = None
|
|
mtime = int(ls.st_mtime)
|
|
entries.append(
|
|
{
|
|
"id": child_rel,
|
|
"object": "session.environment.filesystem.entry",
|
|
"name": name,
|
|
"path": child_rel,
|
|
"type": entry_type,
|
|
"bytes": size,
|
|
"modified_at": mtime,
|
|
}
|
|
)
|
|
page = paginate_in_memory(
|
|
entries,
|
|
id_fn=lambda entry: cast(str, entry["id"]),
|
|
limit=limit,
|
|
after=after,
|
|
before=before,
|
|
order=order,
|
|
)
|
|
return {
|
|
"object": "list",
|
|
"data": page.data,
|
|
"first_id": page.first_id,
|
|
"last_id": page.last_id,
|
|
"has_more": page.has_more,
|
|
}
|
|
|
|
def _read_file(
|
|
self,
|
|
rel: str,
|
|
resolved: Path,
|
|
*,
|
|
limit: int | None = _DEFAULT_READ_LIMIT,
|
|
) -> _WorkspacePayload:
|
|
"""Build the file-content payload for a resolved file.
|
|
|
|
Text files are UTF-8 decoded and line-capped at ``limit``; binary
|
|
files are base64-encoded. Both are byte-capped at
|
|
:data:`_MAX_READ_BYTES`. Shape matches the runner's file-content
|
|
response, including the mimetype guess.
|
|
|
|
Reads at most ``_MAX_READ_BYTES`` from disk (like the runner's
|
|
bounded read) rather than slurping the whole file, so opening a
|
|
multi-GB file in the viewer can't OOM the host process.
|
|
"""
|
|
try:
|
|
with resolved.open("rb") as fh:
|
|
# One extra byte lets us detect (and flag) truncation
|
|
# without loading the rest of a large file into memory.
|
|
capped = fh.read(_MAX_READ_BYTES + 1)
|
|
except OSError as exc:
|
|
raise WorkspaceReaderError(404, "not_found", f"Path {rel!r} not found") from exc
|
|
|
|
return self._file_content_payload(rel, capped, limit=limit)
|
|
|
|
def _file_content_payload(
|
|
self,
|
|
rel: str,
|
|
raw: bytes,
|
|
*,
|
|
limit: int | None,
|
|
) -> _WorkspacePayload:
|
|
"""Assemble the file-content dict from raw bytes."""
|
|
content_type_guess, _ = mimetypes.guess_type(rel)
|
|
truncated = False
|
|
capped = raw
|
|
if len(capped) > _MAX_READ_BYTES:
|
|
capped = capped[:_MAX_READ_BYTES]
|
|
truncated = True
|
|
|
|
text: str | None = None
|
|
try:
|
|
text = capped.decode("utf-8")
|
|
except UnicodeDecodeError as exc:
|
|
# A byte-cap truncation can split a multi-byte codepoint at the very
|
|
# end, which would otherwise flip an oversize *text* file to base64.
|
|
# When the only invalid bytes are a partial trailing codepoint (the
|
|
# error starts within the last 3 bytes of the truncated buffer),
|
|
# drop them and retry — matching the runner's boundary-safe
|
|
# truncation so the same file serves as text from either side. A
|
|
# genuinely binary file has invalid bytes earlier in the buffer, so
|
|
# this guard doesn't rescue it and it falls through to base64.
|
|
if truncated and exc.start >= len(capped) - 3:
|
|
capped = capped[: exc.start]
|
|
text = capped.decode("utf-8")
|
|
|
|
payload: _WorkspacePayload = {
|
|
"object": "session.environment.filesystem.file_content",
|
|
"path": rel,
|
|
"content_type": content_type_guess,
|
|
}
|
|
if text is not None:
|
|
if limit is not None:
|
|
lines = text.splitlines(keepends=True)
|
|
if len(lines) > limit:
|
|
text = "".join(lines[:limit])
|
|
truncated = True
|
|
data = text.encode("utf-8")
|
|
payload["bytes"] = len(data)
|
|
payload["truncated"] = truncated
|
|
payload["encoding"] = "utf-8"
|
|
payload["content"] = text
|
|
else:
|
|
payload["bytes"] = len(capped)
|
|
payload["truncated"] = truncated
|
|
payload["encoding"] = "base64"
|
|
payload["content"] = base64.b64encode(capped).decode()
|
|
return payload
|
|
|
|
# ── Search ─────────────────────────────────────────────────────
|
|
|
|
def search(
|
|
self,
|
|
query: str,
|
|
*,
|
|
include: str | None = None,
|
|
exclude: str | None = None,
|
|
limit: int = 500,
|
|
) -> _WorkspacePayload:
|
|
"""Search files by substring + glob filters, like the runner.
|
|
|
|
:param query: Case-insensitive substring matched against name and
|
|
relative path. Whitespace-only yields an empty result.
|
|
:param include: Comma-separated include globs (VSCode/Cursor
|
|
subset), e.g. ``"*.ts,src/**"``.
|
|
:param exclude: Comma-separated exclude globs.
|
|
:param limit: Maximum results (capped at 500 by the caller).
|
|
:returns: A list payload of matching file entries.
|
|
"""
|
|
q = query.strip().lower()
|
|
if not q:
|
|
return {"object": "list", "data": [], "has_more": False}
|
|
|
|
inc = [re.compile(_glob_to_regex(p), re.IGNORECASE) for p in split_glob_list(include)]
|
|
exc = [re.compile(_glob_to_regex(p), re.IGNORECASE) for p in split_glob_list(exclude)]
|
|
|
|
results: list[_WorkspacePayload] = []
|
|
scanned = 0
|
|
truncated = False
|
|
for dirpath, dirnames, filenames in os.walk(self._root):
|
|
rel_dir = os.path.relpath(dirpath, self._root)
|
|
# Prune excluded subtrees so a "**/node_modules" pattern
|
|
# avoids descending, matching the runner's search walk.
|
|
kept = []
|
|
for d in sorted(dirnames):
|
|
dp = os.path.normpath(os.path.join("" if rel_dir == "." else rel_dir, d))
|
|
if any(r.match(dp) for r in exc):
|
|
continue
|
|
kept.append(d)
|
|
# Spend the scan budget on the real tree first, as the runner does.
|
|
kept.sort(key=lambda d: d in _DEFAULT_DEPRIORITIZED_DIRS)
|
|
dirnames[:] = kept
|
|
scanned += len(kept)
|
|
for fname in sorted(filenames):
|
|
# Counted per entry: a per-directory check lets one huge
|
|
# directory overshoot the budget before `truncated` trips.
|
|
scanned += 1
|
|
if scanned >= _SEARCH_SCAN_BUDGET:
|
|
truncated = True
|
|
break
|
|
p = os.path.normpath(os.path.join("" if rel_dir == "." else rel_dir, fname))
|
|
if exc and any(r.match(p) for r in exc):
|
|
continue
|
|
if inc and not any(r.match(p) for r in inc):
|
|
continue
|
|
if q not in fname.lower() and q not in p.lower():
|
|
continue
|
|
try:
|
|
st = (Path(dirpath) / fname).stat()
|
|
size: int | None = st.st_size
|
|
mtime: int | None = int(st.st_mtime)
|
|
except OSError:
|
|
size = None
|
|
mtime = None
|
|
results.append(
|
|
{
|
|
"id": p,
|
|
"object": "session.environment.filesystem.entry",
|
|
"name": fname,
|
|
"path": p,
|
|
"type": "file",
|
|
"bytes": size,
|
|
"modified_at": mtime,
|
|
}
|
|
)
|
|
if len(results) >= limit:
|
|
break
|
|
# A query matching little or nothing never fills the result cap,
|
|
# so the walk needs its own bound -- the same one the runner
|
|
# applies, so search behaves identically whether the agent is awake.
|
|
if truncated or len(results) >= limit:
|
|
break
|
|
results.sort(key=lambda entry: cast(str, entry["path"]))
|
|
return {
|
|
"object": "list",
|
|
"data": results,
|
|
"has_more": len(results) >= limit,
|
|
"truncated": truncated,
|
|
}
|
|
|
|
# ── Changed files / diff ───────────────────────────────────────
|
|
|
|
def changes(self, session_id: str) -> _WorkspacePayload:
|
|
"""List changed files, mirroring ``list_filesystem_changes``.
|
|
|
|
Git workspaces report the working-tree diff (``git status``);
|
|
non-git workspaces report an empty list because the host has no
|
|
access to the live agent's in-memory edit history.
|
|
|
|
:param session_id: Session id (used only by the edit-tracking
|
|
registry; ignored in git mode).
|
|
:returns: A list payload of changed-file entries.
|
|
:raises WorkspaceReaderError: 500 when ``git status`` fails.
|
|
"""
|
|
try:
|
|
raw_changes = self._registry.list_changed_files(session_id, limit=10_000)
|
|
except GitStatusUnavailable as exc:
|
|
raise WorkspaceReaderError(500, "git_status_failed", exc.reason) from exc
|
|
data = [
|
|
{
|
|
"object": "session.environment.filesystem.entry",
|
|
"path": rec["path"],
|
|
"name": rec["path"].split("/")[-1],
|
|
"status": rec["status"],
|
|
"bytes": rec.get("bytes"),
|
|
"modified_at": rec.get("modified_at"),
|
|
"lines_added": rec.get("lines_added"),
|
|
"lines_removed": rec.get("lines_removed"),
|
|
}
|
|
for rec in raw_changes
|
|
]
|
|
return {"object": "list", "data": data, "has_more": False}
|
|
|
|
def diff(self, session_id: str, relative_path: str) -> _WorkspacePayload:
|
|
"""Return before/after content, mirroring the runner diff endpoint.
|
|
|
|
:param session_id: Session id (git mode ignores it).
|
|
:param relative_path: Path relative to the workspace root.
|
|
:returns: A file-diff dict with ``before``/``after`` strings.
|
|
:raises WorkspaceReaderError: On invalid path, git failure, or a
|
|
path not in the changed-files registry (404).
|
|
"""
|
|
try:
|
|
relative_path = _validate_path(relative_path)
|
|
except InvalidPath as exc:
|
|
raise WorkspaceReaderError(400, "invalid_path", str(exc)) from exc
|
|
if not relative_path:
|
|
raise WorkspaceReaderError(400, "invalid_path", "Cannot diff the workspace root")
|
|
|
|
try:
|
|
record = self._registry.get_changed_file(session_id, relative_path)
|
|
except GitStatusUnavailable as exc:
|
|
raise WorkspaceReaderError(500, "git_status_failed", exc.reason) from exc
|
|
if record is None:
|
|
raise WorkspaceReaderError(
|
|
404,
|
|
"not_found",
|
|
f"Path {relative_path!r} is not in the changed-files registry for this session",
|
|
)
|
|
|
|
is_deleted = record.get("status") == "deleted"
|
|
before: str | None = self._registry.get_baseline(relative_path)
|
|
after: str | None = None
|
|
if not is_deleted:
|
|
resolved = self._resolve(relative_path)
|
|
try:
|
|
# Bounded read (like _read_file) so a huge changed file can't
|
|
# OOM the host; the diff view caps at _MAX_READ_BYTES anyway.
|
|
with resolved.open("rb") as fh:
|
|
raw = fh.read(_MAX_READ_BYTES)
|
|
after = raw.decode("utf-8", errors="replace")
|
|
except OSError:
|
|
after = None
|
|
return {
|
|
"object": "session.environment.filesystem.file_diff",
|
|
"path": relative_path,
|
|
"before": before,
|
|
"after": after,
|
|
}
|