Repository navigation
Fix image attachments that overflow the request body - #124
Closed
steimerbyte wants to merge 50 commits into
Closed
steimerbyte wants to merge 50 commits into
steimerbyte wants to merge 50 commits into
Conversation
Two complementary features for the right panel: a real file editor in the
file viewer tab, and a top-bar Terminal button that opens PTY-backed
shell sessions as new tabs.
## File editing in web UI
Adds an Edit mode to the file viewer (Source / Preview / Edit / Diff).
Editing is enabled in `getAllowedFileRoots()` and writes through a new
`PUT /api/files/[...path]?type=write` handler.
- Atomic writes: temp-file + `rename` so a crash mid-write leaves the
original intact
- mtime-based conflict detection: clients send the mtime they opened the
file with; a 409 on stale mtime surfaces a "Reload from disk" banner
in the editor
- Realpath symlink guard before write so a symlink cannot redirect the
PUT outside the allowed-roots sandbox (same pattern as POST upload)
- 256KB size cap (same as read preview limit), auth-required via
`isApiRequestAllowed`
- Ctrl+S save, yellow unsaved-changes dot, discard button, conflict
banner with reload action
- Hydration fix: `disabled={!selectedCwd}` rendered `true` on the server
and `null` on the client, crashing the page on first load with a React
hydration warning. Switched to `disabled={!selectedCwd || undefined}`
so neither side emits a `disabled` attribute when no cwd is selected
## Browser terminal
Adds a Terminal button to the top toolbar with a dropdown that lists
active terminals and offers a "New terminal" entry. Each terminal opens
as a new right-panel tab backed by xterm.js.
- Backend uses `script(1) -qfc "bash -i" /dev/null` to allocate a real
PTY. Bun's native PTY paths (`@lydell/node-pty`, Microsoft node-pty,
Bun.Terminal) are all unusable from a Bun server process today
(SIGHUP on spawn, no Linux prebuilds, no readable stream, or all
three); a separate file in the manager explains the workaround
- API surface: `POST /api/terminal` to spawn, `GET /api/terminal` to
list, `DELETE /api/terminal/[id]` to kill, `GET /api/terminal/[id]/stream`
for SSE output, `POST /api/terminal/[id]/input` for stdin,
`POST /api/terminal/[id]/resize` for SIGWINCH
- Per-terminal lifecycle: idle sweep kills terminals with no activity
for 30 minutes; SIGTERM/SIGINT handlers tear everything down; tab
close sends DELETE; SPA tab nav triggers cleanup through the manager
singleton stored on `globalThis` so Next.js hot-reload doesn't orphan
shells
- xterm.js renders in a JetBrainsMono NF font (matching Zed) at 14px
with light/dark theme parity to the rest of the app
- Tab system: `Tab` is now a discriminated union of `FileTab` and
`TerminalTab`, so a single TabBar hosts both kinds without separate
state machines
Not included: real PTY control sequences for vim/less/top (these need
a working native PTY library, which is blocked on Bun compatibility
today — see lib/terminal-manager.ts).
…P_WEB_ALLOWED_ROOTS
Up/down navigation in the file explorer so users can browse freely
within an allowed-root set, instead of being pinned to the project
root.
## What's new
- `Breadcrumb` above the tree with clickable path segments. Click any
segment to jump directly to that depth.
- **Click a folder name** in the tree to navigate into it (replaces the
explorer's root view). **Click the chevron** to expand or collapse
the same directory without navigating — separates "peek" from "open".
- **Up button** in the breadcrumb bar moves one directory up.
- **Home button** navigates to the OS-level home (`~` from
`/api/home`, which exposes `os.homedir()`), not the project root.
- `currentPath` state inside the explorer separates the directory the
tree is showing from the project's `cwd` prop, which still anchors
git status, relative paths, and uploads.
- When the `cwd` prop changes externally (user picks a new project),
`currentPath` snaps back to the new root and expanded state is reset.
- Auto-refresh the listing every 5 seconds. Bumping an internal
refresh key re-fetches entries and git status without disturbing
navigation state — expanded paths, currentPath, and highlights all
survive the round-trip.
## OMP_WEB_ALLOWED_ROOTS
`lib/allowed-roots.ts` now reads an `OMP_WEB_ALLOWED_ROOTS` env var at
startup. Comma-separated absolute paths extend the set of directories
the file browser can navigate into beyond the omp session cwds. This
unblocks the new Up button when it walks above the project root.
Example:
```
OMP_WEB_ALLOWED_ROOTS=/,/home,/workspace
```
The per-path server check (`isFilePathAllowed` -> realpath symlink
guard) still protects individual file access, so a path can only be
opened when the running process has OS-level read permission.
## Files
- `components/FileExplorer.tsx` - Breadcrumb component, currentPath
state, navigate handlers, separate chevron-click handler,
auto-refresh interval.
- `app/api/home/route.ts` - tiny endpoint returning `{ home }` so the
client knows the user's `~` without hard-coding it.
- `lib/allowed-roots.ts` - reads `OMP_WEB_ALLOWED_ROOTS` into the
additional-roots set.
…usable pop animation
Persistent CPU/RAM display in the top toolbar, with a click-to-open
popover showing the last 10 seconds as a sparkline per metric. Also
extracts a reusable entrance animation into a global utility so future
panels can adopt the same look without redefining the keyframes.
- `components/SystemStatsBadge.tsx` renders two values (`CPU %` and
`RAM %`) with a coloured dot per metric. Green < 70 %, amber 70-90 %,
red >= 90 %.
- Polls `/api/system-stats` every 5 s by default. When the popover is
open, polling switches to 1 s so the sparkline stays smooth.
- Hovering the badge shows a native `title` with loadavg, memory
detail, and process pid/rss for quick debugging.
- Clicking the badge (or pressing it via keyboard) opens a popover
anchored to the bottom-right of the badge. Escape or outside-click
closes.
- Two SVG sparklines (CPU, RAM) draw the last 10 seconds. Y axis is
fixed to 0-100 %, dotted 50 % guide line, end-point dot in the
current colour. Live sample count is shown in the header.
- Footer shows cores, 1m/5m/15m loadavg, memory used/total, and the
server process pid/rss.
- A client-side ring buffer (`history`) holds the last 10 s of
samples. The popover re-renders on each new tick.
`app/api/system-stats/route.ts` returns:
- `cpuPercent` - derived from two `os.cpus().times` samples (delta of
summed user/nice/sys/idle/irq across all cores divided by wall-clock
delta), capped to [0, 100] so a fully-saturated N-core box reports
100 % rather than 100*N %.
- `cpuCores`, `loadAvg` (1m/5m/15m)
- `memory` - total / used / free bytes and used %
- `process` - pid, rss, heap, uptime
- `sampledAt` - ms timestamp for client-side bookkeeping
The previous sample is stored on `globalThis` so module hot-reload
during dev still computes a meaningful delta on the next request.
Adds a reusable utility class to `app/globals.css`:
```css
.omp-pop-in { animation: omp-pop-in 360ms ease-out both; }
.omp-pop-in::after { animation: omp-pop-in-wash 620ms ease-out both; }
```
`@keyframes omp-pop-in`: translateY(-24px) to 0 with blur(6px) fade-in,
blue accent shadow pulse at the midpoint, and a skewed light gradient
that sweeps left to right via the `::after` pseudo-element.
`prefers-reduced-motion: reduce` disables it.
The popover itself uses the class so it animates on every mount. Any
future panel can drop the class to inherit the same look.
- `app/api/system-stats/route.ts` (new) - system + process stats
endpoint with delta-based CPU percentage.
- `components/SystemStatsBadge.tsx` (new) - badge + popover with
sparklines, ring buffer, poll-rate switching, click-outside
dismissal.
- `app/globals.css` - reusable `.omp-pop-in` keyframes + utility class.
- `components/AppShell.tsx` - mount the badge in the top bar.
- Migrating the existing `.session-info-popover` element in
`AppShell.tsx` to use the new `.omp-pop-in` class so the inline
`<style>` block can be removed. Trivial follow-up once this lands.
- Adding `omp-pop-in` to `TerminalViewer.tsx` so terminal tabs animate
in. Depends on the terminal feature shipping first.
The Basic Auth challenge made every locked browser show the native dialog, which cannot be styled and offers only a reload. A redirect to /login with a real form is the way in. Sessions are HMAC-SHA256 signed cookies bound to the active credential, so changing the password invalidates every live session. The credential is the env password, or the stored scrypt digest's salt:hash fingerprint when the lock runs from the settings file - omp-web cannot read a stored password back, and the digest rotates with the password just as well. Basic Auth still works, so curl, the reverse proxy and existing clients are unaffected. The cookie is httpOnly, SameSite=strict, host-only, and not marked secure because omp-web is served over plain HTTP; that flag must become true behind TLS termination.
The settings panel said the username was fixed at "omp" while the runtime already accepted any value of OMP_WEB_USERNAME. After the env was cleared the user could no longer manage the username at all from the UI - they had to add the env back. Move the username into the credential file alongside the password digest so the settings panel owns it. Priority order in getExpectedUsername: file.username, then OMP_WEB_USERNAME, then the built-in default. The file wins when both are set so an operator who set the env once and never touched the panel does not have their config silently overridden. The username is part of the session cookie secret via resolveSessionSecret, and rotating it must invalidate live cookies. setWebUsername clears the verification cache so the next request from any browser lands on /login. Atomic username+password rotation: setWebPassword now accepts a username alongside the password and writes both in one fsync + rename, so a settings panel cannot land half-written when the operator saves both at once. Read-only copy is dropped from AccessConfig; instead a Username field sits above the password inputs with its own save button. The misleading "the fixed username ..." line is gone.
The fallback page — the one a client that cannot follow a redirect sees — only offered text telling the reader to reload and answer a Basic Auth prompt. Add a full-width button that animates on click and then sends the browser to /login, which is where a person can actually type a credential. The button is still until pressed; only the press animates. It lands on the accent colour, then navigates. prefers-reduced-motion collapses the animation to a single frame rather than removing the navigation. Also stop naming the username on that page. It was hardcoded to `omp` and outlived OMP_WEB_USERNAME — the page now says "your username" and lets /login show the configured one, so a locked server no longer advertises half of its credential to anyone who can load the page.
The focus ring was the default browser blue while the press animation already landed on omp's titanium deep-blue. Both now read from the same pair.
The press animation landed on omp's titanium deep-blue, but the omp mark on every screen is the magenta-violet-cyan gradient from OmpWordmark, and the login page shows that mark directly above the form. The button now settles on the gradient's violet mid-stop with a magenta border, so the two screens read as one design instead of two palettes. Colours converted from the logo's oklch stops so they cannot drift from it: oklch(0.7 0.24 340) -> #F84FCC, oklch(0.62 0.21 295) -> #9362F4, oklch(0.81 0.14 200) -> #00DBE4.
useTheme.applyOmpPalette rewrites every design token from /api/theme at runtime, so the accent in globals.css is only a default and never what the operator actually sees. The 401 fallback page had its press animation hardcoded to one omp palette, which meant it disagreed with the login button the moment anyone picked a different theme. Both buttons now resolve the same token. Setting the accent to purple in omp's theme settings paints the login button and the fallback button alike, with no code change and no second place to remember.
Two pieces of the auth work never made it onto this branch, and the merged result was silently weaker than either source branch: resolveSessionSecret keyed on the password alone, so rotating the username from the settings panel left every live cookie valid. It now folds in the username exactly as it folds in the password, for both the environment and the stored-digest path. The session key was still derived from a fixed module-level salt, which meant a cookie survived a server restart indefinitely. The salt is now random per process, so restarting the server invalidates every outstanding session while the credential stays put. The expiry test hardcoded the old salt and the old password-only secret, which is why it could not have caught either gap: it now pulls the salt from the module and mirrors the current secret.
The login form only ever collected a password while the API required both halves of the credential, so a server configured with anything other than the default `omp` username was unreachable through the browser: the form sent no username, the server compared an empty string against the configured name, and the failure came back as the same generic "Sign-in failed." as a wrong password. The only way in was Basic Auth, which is exactly the dialog this page replaced. The field sits above the password, defaults to omp, and is required. The lead text no longer names a fixed username, since that stopped being true when the username became configurable in the settings panel.
A wrong password previously only printed a red line of text; the button just sat there. The 401 fallback page already had a press animation, so the two screens disagreed about what a click feels like. The button now runs the same 480ms curve, tinted with the theme's danger colour, and recoils once at 62% before settling back — the shake is what separates "no" from "try again" without needing a second control. The button is keyed on a rejection counter rather than having the class toggled: React reuses a node whose class is unchanged, so re-adding the same class after the second failure would have played nothing.
page.tsx has wrapped the username input in a `.fieldGroup` since the field was added, but login.module.css never defined the class. A CSS Module resolves an unknown name to undefined, so the wrapper came out with no border, no background and no height — the input sat directly on the card while the password field beside it had a proper well. In the dark theme that read as a label with a hole under it. Defined it with the same tokens as .control, and gave the inner input display block plus full width: .input sizes itself with flex, which does nothing in a block wrapper, so the height was coming out wrong even once the border existed.
The password section still described the lock as Basic Auth with a hard-coded username, which stopped being true when the sign-in form and the access panel landed. Three claims were left stale: - the username is configurable, via Settings or `OMP_WEB_USERNAME`; - host allowlist entries accept `*` and `*.suffix`, not just exact names; - a restart ends every session, because the cookie key is salted per process. The resolution order for the username is documented as the code implements it — the stored field wins over the environment, the opposite of what the parallel `OMP_WEB_PASSWORD` override does. Flagging that is the whole point of writing it down; the two variables look symmetric and are not. Project Structure gains the routes, components and lib modules the last three feature branches added, verified against the tree rather than from memory.
The 401 page and the sign-in button each had their own copy of the same press
curve, and nothing else in the app had any press feedback at all. Clicks
elsewhere were silent, so two surfaces that look related did not feel related.
Six utility classes in globals.css now carry that one curve, and the elements
that were missing it pick them up:
- `omp-press` for a plain button — the 180ms dip, overshoot, settle;
- `omp-press-tint` for a control that opens something, where a scale alone is
invisible on a full-width bar, so the accent wash carries it instead;
- `omp-press-scale` for an element whose own transform matters. A keyframe
animation replaces the transform property outright, so the chevron that
rotates to show a collapsed state and the @-mention button that centres
itself with translateY(-50%) would both have jumped. Routing those through
the `scale` property composes with the transform already there;
- `omp-slide-in-{left,right,down,up}` for panels, with the direction chosen
from the side the surface opens on;
- `omp-modal-{backdrop,panel}` for dialogs, split because the backdrop only
fades and the panel scales — one class on both would drag the dimmed area
around with the scale.
Wired into the terminal dropdown and viewer, the explorer navigation and
breadcrumbs, both sidebar toggles, the session-sidebar collapse headers, the
SearchableSelect that every model and provider picker is built from, and the
add-provider modal. 27 elements across 7 components.
Two things deliberately left alone. The sidebar containers animate `width` on
desktop and `transform` on mobile, so a slide-in entrance would fight the
transition that is already there. And SessionSidebar keeps its local
AnimatedDropdown: it already does a mount-then-fade that CSS `animation`
cannot, because the exit needs the node to outlive the animation.
Every class is inert on `:disabled` and collapses under
prefers-reduced-motion. No will-change: the provider grid mounts 141 animated
elements, and promoting that many layers would cost more than it saves.
Also fixes TerminalViewer, where `status` sat in the useEffect dependency list.
That tore down and rebuilt the whole xterm instance on every
connecting → running → exited transition, wiping the scrollback each time. The
status read inside the EventSource error handler now comes from a ref.
The badge showed 30.7 GB of RAM on a machine with 8 GB. os.totalmem() is sysconf(_SC_PHYS_PAGES) * _SC_PAGESIZE, which on Linux reports the host the kernel booted on — so inside the LXC container on agent-pc it answered with the HP mini PC's 32 GB rather than the container's own 8 GB cap. The percentage was computed against that inflated total, which made 2 GB of real usage read as a third full. Memory now takes the smallest of three readings: the cgroup v2 memory.max, the cgroup v1 memory.limit_in_bytes, and MemTotal in /proc/meminfo. The cgroup spellings of "no limit" are recognised — the literal max, and the v1 sentinel at 2^60 — so a machine without a cap keeps the honest os.totalmem(). Taking the minimum is deliberate: a limit we mis-detect can lower the figure but never inflate it, and an unreadable file only leaves the host value standing. Available memory switched from os.freemem() to MemAvailable at the same time. freemem counts page cache as used, so an idle container that had been up for a while reported its 8 GB as nearly full. MemAvailable is what the kernel itself considers claimable. It is clamped to the resolved total, since the two come from different files and a container at its cap can report slightly more available than it owns. Core count is read from the cpuset cgroup, which is the same six cores os.cpus() reported — so this is not a fix, it is a name. On agent-pc /sys/devices/system/cpu/possible lists 0-11 while cpuset.cpus.effective is 0-5: the host has twelve, the container may use six, and the badge said six without saying why. Both totals now travel with the source that produced them and the badge prints it in the popover, so "the number disagrees with free -h" resolves to "that is the container's cap" instead of looking like a bug. The header also shows the absolute figures next to the percentages, since the total is the number people check against nproc and free -h. The reading logic moves to lib/system-resources.ts: Next.js rejects any export from a route module that is not on its handler whitelist, so the functions could not be tested where they lived.
The file API had exactly three verbs: read, upload, write. There was no way to move, copy, rename, create or delete anything, which is what the explorer is missing to feel like a file manager rather than a viewer. The interesting part is not the verbs, it is where they are allowed to act. getAdditionalAllowedRoots() includes `/` unconditionally so the breadcrumb and the Up button can reach the filesystem root. That is harmless for reading, because the OS refuses a read the process has no permission for — the allowlist is not what stops you. It stops nothing at all for writing. renameSync between two system paths needs write permission on both sides and no read permission on either, so a write guard built on the read allowlist would have been arbitrary write on every path the service can reach, behind a button. lib/write-access.ts therefore builds its own root set: the session cwds and project roots the read allowlist already collects, minus the always-on `/`. The home directory is excluded as well, or the same hole would reopen one level down. Every comparison runs through realpath, so a symlink planted inside a project cannot redirect a write out of it, and a path that does not exist yet is judged by its deepest existing ancestor. Copy and move differ on purpose. A copy only writes the destination, so the source is checked for readability; requiring write access to copy a file would be surprising and is not what copying does. A move destroys the source, so both ends must be writable. The route is /api/file-actions/[...path], not /api/files/[...path]/actions: Next.js rejects a segment after a catch-all, so the prefix had to be split to keep the catch-all last in both files. Three refusals are deliberate rather than incidental. A destination that already exists is a 409, never a silent overwrite — the user cannot see what was there before it is gone. Copying a folder into itself is a 400, because it recurses until the disk is full and the size guard would only notice after the writes started. And a cross-device move is reported instead of falling back to copy-then-delete, which would leave a half-moved tree if the process died in between. Two bugs found by the tests while writing them. destinationExists used statSync, which follows symlinks, so a link with a deleted target read as a free destination and the write would have gone through it to wherever it pointed; lstatSync is the correct call. And the first version of the write tests used os.tmpdir() as their 'forbidden' location, which passed for the wrong reason on this machine — a session has a cwd in /tmp, so /tmp is legitimately writable. They now use /etc and /usr, which no session cwd will ever be. filePathFromSegments moved from the files route to lib/file-paths.ts, where the other path helpers live and where both routes can reach it. Verified: 10 unit tests, 568 suite tests, and 25 assertions against a running server covering all five operations plus traversal, symlink escape, clobber, cross-device, and system-path refusals.
…er dialog Two pieces, both on top of the file-actions API from the previous commit. FileContextMenu is a new component the tree rows call into. Right-click on a file offers Copy path, Rename, Delete; on a folder it adds New folder, Copy to and Move to. The split is not cosmetic — Copy path only makes sense for something with a path, and only a directory can be the target of a copy or a move, so offering either on the wrong row type would be offering an action that can only fail. Rename and New folder are inline forms in the panel rather than window.prompt. The path is already in component state, and a native prompt would either drop it or make the user retype the directory they are standing in. Copy to and Move to still use a prompt for now; they call the same API and the call site is marked, because FileBrowserDialog replaces them next. Delete is two-step. The first click opens a confirmation with the focus on Cancel, and a folder additionally says that its contents go too. There is no path through the menu that deletes without a confirmation. Client-side validation runs before the request, not only at the API: a name with a slash, a dot segment, or a NUL is refused in the component and reported to the user, so a traversal is never attempted in the first place. The API rejects it too — the two checks are not redundant, one is for the user and one is for the boundary. FileBrowserDialog is a standalone centered overlay for picking a target directory, built on the existing omp-modal-backdrop and omp-modal-panel rather than a new overlay system, because the repo has no portal infrastructure to reuse. It reads through the same /api/files listing route the explorer uses, so the dialog sees the same path boundaries and a 403 from the write API does not surface only after confirming. It takes its navigation entirely from props, which is what lets the next phase reuse it for copy, move, download target and new-folder-here without changing any call site. Two details worth keeping: files are listed but rendered as non-interactive rows rather than hidden, so a folder sitting next to a file explains why one is clickable and the other is not; and the confirm button is gated on the raw load error rather than on a dismissed-error flag, so dismissing the message cannot make a directory that never loaded confirmable. Verified with throwaway harnesses that mount the real components: 15 checks on the menu (action set per type, traversal issues no request and is reported, the first Delete click does not call the API and a confirmation appears) and 20 on the dialog (renders nothing when closed, navigation, breadcrumb, confirm yields the current directory, Escape and backdrop cancel, error state blocks confirm). Harness, jsdom and all probe files removed.
Der Explorer bekommt eine Nautilus-artige Icon-Toolbar in den Kopf: New folder, Refresh, Upload sowie — sobald eine Auswahl vorliegt — Copy to…, Move to…, Download, Properties und Clear. Auswahl laeuft ueber Strg/Cmd-Klick, nicht ueber den einfachen Klick: ein Dateimanager lebt davon, dass ein Klick eine Datei oeffnet. Nimmt man den Klick fuer die Auswahl, muss jeder Nutzer zweimal klicken, um etwas zu sehen. Der Modifikator kostet nur einen Finger und bringt die gewohnte Mehrfachauswahl mit, ohne die Namensspalte wie eine Checkbox-Spalte zu verschieben. Copy to / Move to im Rechtsklick-Menue nutzen jetzt FileBrowserDialog statt window.prompt. Der Dialog liegt als Geschwister neben dem Menue und nicht darin: nur so greift sein Backdrop (z-index 1100 ueber den 320 des Menues) und die Menuepfade fuer Escape, Klick-daneben und Scroll treffen waehrend der Auswahl den Dialog statt sich selbst. Der Dialogzustand liegt im Menue, weil das Menue Quelle und Absicht kennt — der Explorer muesste beides durch Props reichen. Das Namensfeld fuer neue Ordner ist uncontrolled. Es wird genau einmal abgeschickt und geschlossen, ein Render je Tastendruck waere Arbeit ohne Gegenwert; zugleich loest es den Blur-Fall, in dem ein kontrolliertes Feld den Entwurf raeumt, bevor der Submit-Handler ihn liest. Properties nutzt omp-modal-panel. ?type=meta beantwortet nur Dateien und lehnt Ordner mit 400 ab, darum fragt der Dialog fuer Ordner ueber ?type=list und zeigt die Zahl der enthaltenen Eintraege. Eine Aenderungszeit liefert keine der beiden Routen, sie wuerde hier erfunden, also entfaellt die Zeile.
Zwei Bausteine fuer die Nautilus-Angleichung des Explorers: FileImagePreview ist ein ueberlagerndes Panel fuer einzelne Bilder. Die Bytes kommen aus derselben Route wie der DateiViewer — `type=read` liefert fuer Bilder die rohen Bytes, `type=preview` ist der DOCX-Zweig — damit beide Anzeigen dieselben Allowlist-Grenzen sehen. Zoom sitzt an der Cursorposition (der Punkt unter dem Zeiger bleibt unter dem Zeiger), Pan per Drag nur oberhalb der Einpassgroesse, Versatz auf den Ueberhang begrenzt. Der Ladezustand haengt an einer `imageKey` auf dem <img> statt an einem Effekt: bei einem gecachten Bild feuert `load`, bevor React den Handler angehaengt hat, und ein Effekt wuerde den Zustand zurueck auf "loading" setzen. Der `complete`-Check im Effekt schliesst genau diese Luecke. FileBrowserDialog bekommt optionale Ansichtsmodi, Dateiauswahl und Sortierung. Ohne `allowFiles` bleiben Dateien die nicht-interaktiven, ausgegrauten Zeilen aus Phase 3 — nur Ordner sind Ziel eines Copy oder Move, und die sichtbare deaktivierte Zeile erklaert das. Erst `allowFiles` macht sie zu Zielen. Das Raster ist fensterweise gerendert: bei 5000 Eintraegen waeren 5000 Kacheln im DOM und so viele Thumbnail-Requests, wie Bilder darunter sind. Gemessen bei 301 Bildern: 37–42 montierte Kacheln, 108 distinct Requests ueber den ganzen Scroll. i18n in beiden Sprachpaketen synchron.
The preview component and the dialog's view modes were built but reachable from nowhere: no call site passed allowFiles, onSelectFile, view or onViewChange, and FileImagePreview was imported by nobody. Both are wired now. A click on an image row opens the preview instead of the file tab, and a right click on one offers Preview above Copy path — what am I looking at is the more common question than what is its path. Both routes are optional callbacks, so a caller that does not pass onOpenImage gets the old behaviour and images without the callback behave like any other file. The zoom readout was lying. It showed transform.scale, which is relative to the fitted view, so a 3360x2100 image fitted into a 479px stage read "83%" while nothing was scaled down at all, and the toggle labelled as native size jumped to 120%. The readout is now the render scale, so 100% means 100% of the original pixels. The limits follow: 10% and 800% are ten percent and eight times the original, not ten percent of an already-reduced image, and the two buttons compare against those derived bounds. On a four-times-too-large image the old code disabled Zoom out immediately, because scale was already 1 — the floor was unreachable. The step is 1.25 rather than 1.2 so a fitted image reaches 100% in one click instead of overshooting to 104%. Also fixes a gap the toolbar work surfaced: files.expandFolder and files.collapseFolder were referenced by the tree row and defined in neither language file, so the chevron carried the raw key as its accessible name. A scan across every component, hook and app route now finds no undefined keys left. Verified in Chromium end to end: clicking shot.png opened the overlay, the image loaded at 3360x2100 through ?type=read with alt set, and the dialog carried omp-modal-panel. The zoom state machine is checked with 20 assertions against the real component: the readout starts at the fit scale, one click goes above 100%, both buttons stay live in between, the ceiling is exactly 800% and the floor exactly 10%, and Fit is disabled only at the fit position where it would be a no-op. Harness, jsdom and the probe directory removed.
…report the GPU Three gaps the screenshots showed, all confirmed in the browser before and after. Upload and Refresh were rendered twice, stacked: the sidebar header had its own ToolbarIconButton for each, and the explorer toolbar below it had another. The header pair is gone and the toolbar owns both. The green check that confirms a refresh would have been lost with it, so the confirmation moved with the button rather than being deleted: the toolbar refresh now shows it for two seconds and reverts. Two keys that no longer had a caller were removed from both language files, and the key sets are byte-identical again (589 each). The row action that opens an image had no dedicated control, so a large centred view was only reachable by guessing that a click on the row was it. Image rows now carry an explicit button at its own offset (30px from the right, clear of download at 4 and mention at 58), reusing the existing isImagePath check so the button appears for exactly the files a click would preview. It stops propagation, or the row click would open the same preview a second time. FileImagePreview needed no change: the overlay was already fixed-position, flex centred, 960px wide at 88vh. Measured at 1365x768, the panel's centre is (683, 384) against a viewport centre of (683, 384) — exact. The badge never reported the GPU. It now does, from lib/system-resources.ts, and the two premises behind it did not survive measurement: - gpu_busy_percent is an amdgpu attribute, not an Intel one. The kernel documentation says so outright: the amdgpu driver provides a sysfs API for reading how busy the GPU is as a percentage, and this file is used for this. An Intel iGPU has no such counter, and the i915 PMU only offers cumulative engine time, which as a delta a stateless call cannot honestly report. So Intel reports present with a null percentage rather than a fabricated one. - card0 is not a stable path. The index differs per boot, so cards are discovered through a /^card\d+$/ filter and connector entries are dropped. NVIDIA is the only source that spawns a process, and only after a device node confirms the card is there, with a hard 1500ms timeout and stderr discarded. This machine has neither /dev/dri nor any vendor tool, so the route answers 200 in about 12ms with every GPU field null — verified by curl and by the badge reading "gpu: none detected". That distinguishes three cases the em dash used to flatten: no GPU, a GPU whose driver exposes no counter, and a GPU that is merely idle. 18 new tests cover the decision tree through injected filesystem and process deps, including AMD on card1 without card0, an nvidia-smi that times out, that no tool is spawned without an NVIDIA node, and that a figure is never reported without the source that produced it. Full suite: 586 pass, 1 skip, 1 pre-existing proxy failure that also fails on a stashed tree.
The sidebar explorer is a 300px column. Reading a tree, comparing names and scanning the changed-files list in it is cramped work, and there was no way out of it. A fourth toolbar button opens the same explorer full-size over the main area; the sidebar stays, so both views of the folder are visible at once. 1160px by 85vh, between the two sizes this codebase already uses for view-to-click panels (960px for a single image, 720px for a folder list) and wider, because this one is read and operated rather than dismissed. z-index 1300, above the image preview at 1200, because the explorer opens its own overlays inside itself and they would otherwise disappear behind the backdrop. The window's explorer is the real one, not a reduced copy: tree, breadcrumb, toolbar, changes list, git status, and the image preview it renders itself because it owns that state. It deliberately gets no onAtMention — inserting a path writes into a chat the user is not looking at. Recursion is prevented by absence, not by a guard flag: the opener only renders when onOpenPopup is set, and the window passes nothing. Measured in the browser, the button exists once in the DOM after opening, not twice. Opening, expanding omp-web-local, closing and reopening keeps that position and keeps the folder open with its children loaded — the inner instance reports its view state and the outer hands it back on mount. The report effect depends on currentPath and expandedPaths, not on the callback, and sameViewState rejects equal reports, so the two instances cannot drive each other in a loop. Two defects an architecture review caught, both fixed here: - border-radius: 4 without a unit. The browser drops the declaration, so the close button rendered with square corners. The only unitless radius in the repo; now 4px like its siblings. - Escape closed too much. The window, the image preview, the folder picker and properties each listen on document, and stopPropagation does not separate siblings on one node, so one keystroke closed all of them. The nested backdrops now carry data-nested-overlay and the window bails when the keystroke landed inside one. Verified on a real document: with a nested dialog open only the dialog closes, without one only the window. A comment cited FileExplorerPopup.tsx as the reason for keeping the component in FileExplorer.tsx, describing an import cycle the codebase does not have. Reworded to the real reason, matching the PropertiesDialog precedent. Full suite: 586 pass, 1 skip, 1 pre-existing proxy failure.
Right-clicking a row inside the large explorer window opened the menu at the right coordinates but drew it behind the window backdrop, so it appeared displaced while the browser was in fact compositing it underneath. The cause is the z-index, not the placement. The menu sat at 320 — a value that was correct while it only ever opened inside the 300px sidebar, where nothing taller existed. The overlays it can now be opened on top of are not: the directory picker at 1000, the properties and models dialogs at 1100, the image preview at 1200 and the explorer window at 1300. Measured placement against a right click at (400,198): left and top were already correct, position was already fixed, and only the stacking order was wrong. Now 1400, above every overlay in the codebase, so the menu stays visible in each embedding rather than only in the one it was sized for.
A click on a file inside the explorer window opened it in the right-hand
panel, so the window was a launcher and the actual work happened somewhere
else. The window now holds the files itself.
The decoupling is the whole change: FileExplorerWindow used to receive
onOpenFile straight from the outer explorer, the same handler the sidebar
gets, so every click travelled to the panel. That prop is gone. The window
builds its own tab state from the same Tab union TabBar already exports
(TabBar.tsx:23), keyed `file:${filePath}` exactly as AppShell.tsx:668 does,
and reuses handleOpenFile's duplicate guard so a second click on an open file
only focuses it. Nothing in the window touches setRightPanelOpen or
setSidebarOpen — the main page is not involved. The sidebar is byte-identical.
The content reuses FileViewer, the component AppShell.tsx:1798-1810 already
renders for panel tabs. A second renderer would drift from the first on the
first change to either.
TabBar gains drag, opt-in through an onReorder prop: without it the component
behaves exactly as before, which is what keeps the panel bar unchanged. The
pointer pattern comes from FileImagePreview.tsx:335-351 — setPointerCapture
with pointerId, so one code path serves mouse and touch. Not HTML5 draggable,
which does not work on touch. A 4px threshold separates a click from a drag,
and swallowClickRef stops a click from selecting the tab the user was trying
to move. touch-action: none is set per tab only while onReorder is present,
so the bar stays scrollable on touch where drag is not wired up.
The reorder reports the tab that should follow the dragged one, and the
window reorders the array. Reverting the pixel offset instead would move the
tab visually and leave it in place — eight cases checked against the real
handleReorder, including dragging past the last tab and back past the first.
The window is now a flex box that sizes to its content up to 90% of the
viewport: fit-content with max-width 90vw, no fixed height with max-height
90vh. Small when little is open, large when much is. The fixed 1160x85vh is
gone with the comment that argued for it. min-height: 0 on the two scrolling
sections is load-bearing, not cosmetic: flex children default to min-height
auto, which would override max-height and let the content run past the panel
instead of scrolling inside it.
…ab button Two things the screenshots showed. The tree disappeared the moment a file was opened. .explorer and .content were siblings in a column flexbox, both with flex: 0 1 auto, so they stacked: tree on top, content below. At a fixed 90vh the content ate the space and the tree was squeezed to nothing — the user could not navigate any more. They now sit side by side in a .workspace row: the tree keeps 300px and may shrink (min-width: 0, so a long filename cannot push the content out), the content takes the rest. Below 640px they stack again, the same threshold useIsMobile.ts:5 and globals.css already use, with the tree capped at 40vh because it is not what you read on a phone. The comments described the old vertical behaviour and the auto-growth that caused it; they now describe what the rules do. The tab bar was behind tabs.length > 0, so a window without tabs showed no sign that files open as tabs at all. It is now unconditional, with a new-tab button beside it. A new tab opens the current directory as an empty tab with a path field to pick from — not a FileViewer with an empty path, which would only have produced an error. Clicking a file fills the active placeholder where it sits instead of opening a second tab beside it: the user asked for this tab to be filled, and if the file is already open elsewhere the placeholder is dropped and the existing tab becomes active. TabBar gained a startPath on FileTab and an icon-less rendering for a tab without a file, plus a tooltip fallback. All four new keys exist in both locale files; 596 keys, identical sets. 13 assertions against the real components: the row flexbox, the 300px tree with min-width: 0, the content taking the rest, the 640px stack, the new-tab button visible with zero tabs, one tab per click, the picker showing an input rather than an error, and a second empty tab keeping the same start path. Typecheck clean, 586 pass with the same pre-existing proxy failure.
The window backdrop darkened the app underneath but left everything legible, so the sidebar's resize handle still read as a grabbable control — with its tooltip attached — while the window was in front of it. blur(8px) drops the app out of focus instead of merely dimming it: the handle turns into an unremarkable smudge and stops advertising itself. Same value and same convention as SettingsConfig.module.css:9, which already blurs its backdrop. The darkening stays at 0.6 rather than the settings' 0.48, because the explorer is a working surface meant to stay open and its file should not glow the way it does in a settings dialog. This covers the symptom; it does not explain it. A z-index of 220 sitting visually above a 1300 backdrop is still unexplained, and three candidate causes have been ruled out (a z-index without position, a decorative transform in the chain, the sibling relationship). Verified in the built stylesheet: FileExplorerWindow_backdrop__brUKC carries the filter at z-index 1300. Typecheck and lint clean.
…g context Found the cause of the resize handle showing through the explorer window. The sidebar opened a stacking context of its own, and the window was inside it. .sidebar-container got position: relative from app/globals.css:1626 (inside @media (min-width: 641px)) — not from the inline style — and AppShell.tsx paired it with zIndex: 200 in that inline style. position: relative plus a z-index is exactly the pair that opens a stacking context. position: relative alone does not, and neither does a z-index alone, which is why the two halves had to be read together. So the 1300 and the 220 were never compared. The window's z-index was measured against the sidebar's 200, the handle's 220 sat on the root level, and 220 beat 200. A position: fixed element does not escape the stacking context of an ancestor; it is positioned against the viewport, but its stacking level stays that of the ancestor. That is the whole reason the number 1300 could not help. The fix is to remove the context, not to raise the number. zIndex: 200 is gone from the inline style and moved into the @media (max-width: 640px) block at app/globals.css, where the sidebar genuinely has to cover its own backdrop (199). On the desktop it carries no stacking level at all: it is a flex participant sized by width, and nothing in it needs to be lifted. The handle also stops being operable while the window is open. FileExplorer reports popupOpen through a new onPopupOpenChange prop, the sidebar passes it up, AppShell holds explorerWindowOpen and adds .overlay-blocked-resizer to both handles (visibility: hidden, pointer-events: none — visibility rather than opacity, because the element itself has to be out, not merely invisible). The effect depends on popupOpen and not on the callback alone, which would spin: the caller sets state on every report. Unmount reports false as well, so folding the explorer away does not leave both handles dead with no window on screen. Verified in the built stylesheet: .sidebar-container carries position: relative and no z-index, .overlay-blocked-resizer carries the two declarations. 12 assertions on the real files, typecheck clean, lint clean apart from two pre-existing sessionsForDisplay warnings, 586 pass with the same pre-existing proxy failure. The blur from 36a91ad stays — it is a separate improvement, not this fix. I was wrong in an earlier turn: I read the inline style, found no position there, and concluded the z-index was inert. The position was in the stylesheet all along.
… management
A signed-in account could reach the whole machine. The read allowlist has
always contained "/" and the write guard was wired to one of seven write
routes, so the editor's save path checked the *read* roots and then wrote.
Every account therefore saw the same home, the same skills, the same
sessions and the same shells. Admins pi, omp and steimerbyte are exempt by
decision, so an admin deliberately still reaches everything.
Accounts. `bin/web-auth-store.js` gains a separate omp-web-accounts.json
beside the credential file, with its own schema version, lifetime and
ownership so that touching one never rewrites the other. `validateAccountName`
rejects anything that is not /^[a-z_][a-z0-9_-]{0,31}$/ — a name that is not
already a path segment, checked before any mkdir, so `../etc` never becomes a
directory. `verifyCredential` returns a WebIdentity instead of a boolean and
`proxy.ts` forwards it as x-omp-user / x-omp-admin. The session cookie signs
the username, so one account's cookie is not another's. `GET /api/whoami`
exists as the proof, and `POST /api/web-access/users` is admin-only and does
the mkdir. An "Accounts" section in the settings lists, creates, disables and
re-passwords; `OMP_WEB_ADMINS` overrides the default list.
The security boundary is the proxy, and that is now unconditional.
`withIdentity` deletes both headers on every path and only sets them from a
proven identity. Before, the delete lived inside a call guarded by
`identity !== null`, so on an unlocked server — mode "open", no credential
file — the client's own `x-omp-admin: 1` passed through untouched:
`curl -H 'x-omp-user: root' -H 'x-omp-admin: 1' /api/files/etc?type=list`
answered 200 and read /etc/passwd. A delete a branch never reaches is not a
protection, it is dead code. Measured after the fix, on a locked server with
real accounts: a real admin gets 200 on /etc, a normal account 403, and a
normal account sending a forged admin header 403.
Read. `getAllowedFileRoots(identity)` returns the empty set for a missing
identity and does not even reach `listAllSessions` there, because an
unfiltered scan would rebuild the very set the identity narrows. Roots live
in a Map per identity rather than one process-global Set, so one account
widening its own scope cannot widen anyone else's. "/" and
OMP_WEB_ALLOWED_ROOTS are added in the admin branch only. The old comment
claimed "/" was harmless because the OS refuses a read the process has no
permission for; that is false, the service runs as one account and the kernel
only ever sees that one, and the claim is replaced with the measured
behaviour.
Write. `isWritePathAllowed` is now called on the upload and editor-save paths
in app/api/files/[...path]/route.ts and on the agent's cwd, and every entry
point takes an identity with no default.
Directory access. cwd/browse and cwd/validate are closed, default-cwd
validates before it creates anything, and `allowFileRoot` survives only in the
admin branch. It mutates a process-global set, which under one shared process
means one account grants rights to every other.
Ownership. Sessions, terminals and running agents carry an owner. Foreign ones
answer 404 rather than 403, so they cannot be probed, and the agent-running
broadcast filters per subscriber instead of publishing every id. The
terminal's process still runs as the service account, so a shell can read
what the file guards protect; that is stated in docs/authentication.md rather
than papered over.
Per-account state. models.yml and config.yml move to the account's own agent
directory, and the skill lock with them — a shared lock lets one account
report another's skill as installed. Nothing is migrated: a new account
starts empty on purpose, because copying the operator's models.yml would hand
his API key to the first tenant to sign in. Skills stay globally readable
because they are shared tooling, and writing them — including global installs
and updates — is admin-only.
Four bugs found on the way, each with a measurement before and after: a
cross-tenant leak where the omp-cwd scan read the process account's home; a
lockout for a fresh account whose home does not exist yet, whose own fix then
admitted a symlink escape on paths that do not exist; and a flaky session
test whose tamper mutation was a no-op whenever the nonce ended in "A",
green in 98.4% of runs and wrong the rest.
Typecheck clean, eslint clean, 617 pass with the same pre-existing proxy
failure.
… comment The other per-account checks have the same shape as the terminal one, and nothing in the docs said so. File access, workspace validation and session ownership are decided from data the service process itself can write — a requested path, a `cwd` field inside a session file, a terminal id. The session files sit in one shared tree owned by the account the service runs as, and that account can write them, so whoever writes that data decides what it points at: editing the `cwd` of someone else's session file moves that session into your own home as far as the service is concerned. Closing that would mean an OS user per account, not a sharper check. Read these as separation between honest users of one service, not as isolation between people sharing a host. Also: while OMP_WEB_PASSWORD is set there is exactly one account. Any other name is not a user with the wrong password, it is no user at all, and it is rejected like one. OMP_WEB_PASSWORD is a single-key door, not a multi-user configuration; several accounts need the account file. And a comment in app/api/cwd/validate/route.ts described allowFileRoot() as mutating a process-global set on globalThis. That stopped being true when allowed-roots.ts became a Map per identity, and a comment explaining a property that was just removed reads as the justification for a decision that no longer exists. Rewritten, and the reasoning for the admin-only variant added: without an identity the path lands in the unclaimed bucket that only the admin branch reads, which books it as an operator release rather than a personal one. The session-ownership comment in lib/session-reader.ts claimed the cwd sits in the first header line. It does not — head -1 gives type, v, title, source, updatedAt, pad. The statement still holds, it is in a later line of the same file, so the comment now stays true without pointing at a place where a reader would not find it.
lib/write-access.ts drops the filesystem root from the writable roots — on posix and on a drive root on Windows. That line had only ever been read, and a note in the docs called it the reason a compromised admin password cannot overwrite /etc/hostname. Measured by mutation instead of assumed: baseline /etc/hostname -> false the filter removed /etc/hostname -> true So the filter is the guard, and nothing else was holding that line. It would have gone silently: the observation stays 403 either way as long as some other reason happens to deny, which is exactly the case that made me miss it. The test therefore asserts the filtered set rather than the refused result. "isWritePathAllowed refuses /etc/hostname" would still have passed with the filter gone, if the filter were one of two reasons. Removing it now fails five tests; restored, 17 pass.
The line that drops the filesystem root from the writable roots is the whole write boundary for an admin, and until now the only thing written about it was a test count, which goes stale. It now carries the reason and how that was checked: delete the line and isWritePathAllowed flips false to true for /etc/hostname, /etc/passwd and /root/.ssh/authorized_keys — a compromised admin password becomes arbitrary write, SSH keys included. It also states why the neighbouring tests cannot stand in for it. A refusal can have more than one cause, and a test that only observes a 403 passes just as happily when the filter is gone and something else happens to deny. A mutation removes exactly one cause, so that is what the assertion has to be built on. A test count is a number; a reason survives the next person who looks.
The note on the write-set filter listed /root/.ssh/authorized_keys next to /etc/hostname as paths that flip to true once the filter is removed. Measured: /etc/passwd flips, /root/.ssh/authorized_keys does not — /root/.ssh is not present here, and mustExist: true refuses a path that is not there. The claim was inherited from another agent and carried into a comment while shortening it, without being checked against the machine it describes. What is left says *existing* system file, which is the whole difference: the filter is what keeps an admin from writing over a file that is actually there, and that is the case worth stating.
… first The allow-list section described where roots come from and which routes grant them. It did not say that the whole set is per account, that / and OMP_WEB_ALLOWED_ROOTS exist only in the admin branch, that the write boundary is narrower than the read one, or that the identity headers are set by the proxy alone. All four are the difference between the old single-operator behaviour and what the code does now, and this is the file someone opens before touching one of those routes. Each claim was checked against the code rather than carried over: a Map keyed by identity in allowed-roots, the root filter in write-access, the account name pattern and the account file in web-auth-store, the admin list and its override. The last line says the obvious thing plainly. None of it is containment — it is assignment from data the service process can write — and a reader who skips docs/authentication.md should not come away believing otherwise.
The architecture diagram said GET /api/sessions reads the sessions directory
and GET /api/sessions/[id] reads the .jsonl directly, with no condition on
either. That reads as "the tree is everyone's" — and it is, one tree, owned by
the account the service runs as. The filter that makes it per account was in the
code and in no documentation, which is worse than omitting it: the diagram is
the authority someone checks before touching one of those routes.
Three routes and the reason, checked rather than repeated: sessions filter
through listAllSessions({ identity }), the running snapshot and its event
stream filter the ids they publish, and a foreign session or shell answers 404
rather than 403 so that a probe learns nothing. The two SSE ones were a
side-finding — they handed out every running id, and those ids are what the
rest of the API addresses things by, so the filter was bypassable through a
stream nobody thought to guard.
Ownership itself comes from the cwd in the session file, which is why the
containment note below it matters here too: whoever can write that file decides
what it points at.
… needs Creating an account calls mkdirSync on <home-root>/<username> with no permission check and no fallback. On a service that does not run as root the default /home is not writable, so account creation fails outright and the API returns the raw system error with the server's absolute path in it. OMP_WEB_HOME_ROOT was the one variable in this file's own subject matter that no document mentioned, which is why it was unset on a real deployment. That is the whole bug: not a wrong default, an invisible switch. The section says which question each fix answers, because granting the service write access to /home is a far larger grant than account creation needs, and it says the two settings interact — the variable changes where homes are looked for, not where they already are, so an existing account and a moved root have to be decided together.
The window is rendered by FileExplorer, which SessionSidebar mounts inside a container carrying overflow-x: hidden. That clips the window at the sidebar edge, which is what the mobile screenshots show: the panel cut in half down the middle, file names sliced, the title truncated to an ellipsis. position: fixed does not save it. It resolves against the viewport only while no ancestor clips; the moment one does, the fixed element is clipped with everything else. Moving the markup to document.body is the fix, and it is not a new idea here — DirectoryPicker and FileContextMenu have always portalled for exactly this reason. The window was the one overlay that never did. The focus effect moves with the portal and that is the part worth reading: the first render returns null because document.body does not exist on the server, so an effect with an empty dependency list runs once against a panel that was never mounted, panelRef.current is null, and the dialog sits unfocused in the background taking no keypresses. Keying the effect on portalTarget makes it run after the portal actually exists. The Escape listener is unaffected: it is on document either way, and the data-nested-overlay guard at line 854 asks whether a deeper overlay caught the press, which is a DOM question that a portal does not change. Verified: typecheck clean, eslint clean, production build succeeds, and the window does not appear in the server-rendered HTML. Not verified: the on-screen result at 412px — the browser service in this environment failed to open a tab three times, so the geometry claim rests on the CSS and the DOM change, not on a measurement.
The strip scrolled but nothing ever asked it to. New tabs are appended to the right, so on a narrow window the tab you just opened lands outside the visible area and looks clipped — at a 415px viewport the strip is 342px against 360px of tabs, and the fresh tab hangs 40px past the edge while its own tile is intact. In the main panel this never showed because the strip is wider there; the explorer window is where it bites. The infrastructure was already there: tabRefs is a Map from tab id to element, and the strip already carries overflowX: auto. What was missing was the occasion. The effect keys on activeTabId and tabs.length so it also fires when a tab arrives, not only when the selection moves. inline: nearest rather than center, because center would drag half the strip along when switching between two already-visible tabs. scrollMarginInline on the tile keeps it off the edge, so "scrolled into view" does not read as "cut off" — 1px of overhang measured at 415px, against 40px before. Verified at 415px: scrollLeft moves 0 -> 18, which is maxScroll, so the strip lands exactly at the end rather than somewhere in between. Typecheck and eslint clean.
The card already carried omp-pop-in, which plays once on mount and is then finished — a dead state on a login screen, because waiting is the entire point of the page. The loop adds a slow breath underneath the entrance. The two animations do not fight: omp-pop-in drives opacity, filter and box-shadow, the loop only transform. The scale is written as a single value so the transform-origin: top right that omp-pop-in sets survives, and the card never translates out of its own centre. prefers-reduced-motion: reduce turns the loop off. On a login page the alternative to a moving card is not a still card but an eternally moving one, which is exactly what that setting exists to prevent. Measured at 500px viewport, four samples 850ms apart: 452.38 / 453.26 / 457.15 / 455.86 px wide. The peak lands at 1.7s, half of the 3.4s period, which is where the keyframe puts it. With reduced motion emulated the computed animation-name is none and the width holds at 452.
Opening a file left the tree sitting next to the viewer, so a preview rendered as a layer under it and the window carried two things at once. The tree is now the content of its own tab: the first one, always present, never closable. Clicking a file switches the window to that file's tab; a button next to the plus hands the tree back without depending on the tab strip, which on a narrow window scrolls the left edge away. The tab carries kind: "explorer" rather than an empty filePath, and that distinction is the load-bearing part of this change. The existing placeholder-filling branch tested filePath === "" to decide whether a click should replace the active tab — and the tree tab has an empty path, so a second click on an already-open file dissolved the tree tab. The user was left with no way back into the tree. hasPlaceholder now also requires kind: "file". Verified by clicking the same file twice through a round trip: both tabs survive, where before the tree tab was gone. The tree gives up its fixed 300px and the border-right, because there is no neighbour left to separate from. It takes flex: 1 1 auto and width: 100%, which measures 1150px inside a 1152px panel at 1280 viewport and 372px of 374 at 415. The tab cannot be closed, dragged, or dropped before the first tab, so the only way out of a file is never removed. Its title is the plain label, not "Terminal · ..." — the kind check in the tooltip would otherwise have claimed it was a terminal. Not verified: the image path as a tab. imageTarget="openFile" was already set in this instance, so images went to a tab before this change too; what changed is that the overlay no longer draws underneath. No image file was clicked in the test.
The README described the inherited product: one password for the whole interface, no file writes, no terminal, an npm badge for a package this distribution does not publish, and a link to someone else's release page. None of that was wrong about upstream and all of it was wrong here. Rewritten around what actually differs: per-account access, the write guards, the explorer as a tab, the terminal, the portal that makes the window a real overlay. The Accounts section states plainly that none of this is containment — it is assignment from data the service process can write — because the previous wording could be read as a security boundary it is not. The upstream remote is removed. There is no merge path and no automatic tracking now, and the Provenance section says so instead of leaving a reader to discover it from a failed pull. The npm publish workflow is disabled rather than deleted, with the reason in the file: the package name belongs to the upstream line, and the provenance of what it would publish is not ours to assert. Leaving it live would have made every v* tag fail a workflow. CHANGELOG.md is new and covers v0.9.0. docs/release.md now describes the one artifact this repo ships instead of two. Version 0.9.0 in package.json, after the fork's highest tag. Worth being explicit about why that number is also a bit of a fiction: v0.8.7 exists on a line this branch does not descend from, so this is not a continuation of it. It is the first release of an independent distribution and happens to sort after the tags that are already in the repository. Verified: typecheck clean, all four workflow files parse with exactly one on: block each, 615 tests pass. Two failures are pre-existing — confirmed by stashing these changes and re-running: the HTTP_PROXY dispatcher test and web-auth-session's signed-username test, neither touched here.
The terminal was the one per-account boundary that protected only the route. The process runs as one user, the shell got no uid/gid switch, and anyone holding an open terminal could read anything that account could read. Per-account terminals were bookkeeping, not containment. lib/sandbox.ts now builds a bubblewrap argv whose only bind is the tenant's own home. Measured: a foreign home answers `No such file or directory` rather than `Permission denied`, /etc/shadow stays unreadable, and uid=0 inside the namespace is not host root. The namespace maps the fake uid back onto the service account, so the service can still clean up after a tenant — the property an OS user per account would have broken, and the reason for taking this route over `useradd` + sudoers: on the target host sudo demands a password and setpriv answers `setresuid failed`. `sandboxed` is in both terminal responses, so "isolated" is a claim a client can check rather than a comment nobody reads. Without bwrap the shell still opens unconfined and the flag is false — the key is always present, never absent. The plan refuses rather than guesses: a username that would escape its path segment is `bad-username`, a cwd outside the home is `cwd-outside-home`, both with a remediation. Passing such a cwd through only surfaced later as `[Process exited with code 1]` in the SSE stream. Missing bind sources are skipped, because bwrap aborts on them. Also fixes uploads landing in the project root instead of the directory being browsed, and checks OMP_WEB_HOME_ROOT before account creation so a failure names the variable instead of surfacing a raw EACCES from mkdir.
Der Scroll-Container war korrekt (.explorer hatte overflow-y:auto sowie min-height:0 und min-width:0 und scrollte tatsaechlich). Unsichtbar war nur der Griff: die globale Regel in app/globals.css zeichnet ::-webkit-scrollbar mit 4px Breite und Daumen in var(--border) auf var(--bg). Auf einem Fenster, das den halben Bildschirm fuellt, liest sich das als fehlende Scrollbar. Gleiche Werte wie die bestehende .chat-session-scroll-Konvention.
…orie Zwei getrennte Fehler, beide am FileExplorer-Fenster gemessen. Ordnerklick auf Ebene 2 und tiefer tat nichts: der rekursive TreeNode-Aufruf reichte expandedPaths, onToggleExpanded und onContextMenu weiter, aber onNavigate nicht. In handleClick faellt das optionale onNavigate fuer jede Zeile ab Ebene 1 auf den Zweig "nichts tun", waehrend die oberste Ebene navigierte. onNavigate wird jetzt durchgereicht. Die Navigationsleiste hatte nur Home und Up, Back und Forward fehlten voellig. Die Historie ist ein Stack mit Zeiger statt zweier Stapel, sonst laufen Back und Forward auseinander, sobald man zurueckblaettert und dann irgendwo neu hinspringt. Ein neuer Sprung schneidet alles hinter dem Zeiger ab, ein Chevron-Aufklappen fasst den Stack nicht an. sameViewState vergleicht die Historie mit, weil currentPath nach Zurueck und wieder Vor kurzzeitig denselben Wert traegt und das Fenster sonst beim Schliessen seinen gemeldeten Stand verlaesst. Die Leiste sitzt im Breadcrumb, der Pfad gibt Flaeche ab, die Knöpfe nicht, damit sie auf einem Telefon bedienbar bleiben. Back und Forward lesen aus dem Zustand statt aus einem Updater: setCurrentPath in einem setHistory-Updater waere ein Seiteneffekt in einer Funktion, die React in der Entwicklung zweimal aufruft. i18n: vier Schluessel in en und zh-CN. Die hartkodierten englischen Titel "Up one directory" und "Home directory" sind mit auf t() umgestellt.
Konfigurierbar in den UI-Settings, ein Klick fuegt den Text an der Cursorposition in das Eingabefeld ein. Der Serverpfad taugt hier nicht: app/api/settings projiziert ein Schema aus node_modules/@oh-my-pi/pi-coding-agent, und validateSettingValue wirft bei einem unbekannten Pfad. Quick Phrases im Schema einzuhangen hiesse, ein gepinntes Fremdpaket anzufassen. Das Projekt legt alles, was allein die Oberflaeche betrifft, in den Browser-Speicher — omp-sound-enabled, omp-theme-mode, pi-locale, Panel-Breiten, dokumentiert in AGENTS.md. So auch hier. Der Composer hatte bereits die richtige Routine: insertText in ChatInput.tsx liest selectionStart und selectionEnd, pflegt valueRef und State gemeinsam, raeumt ein offenes @-Token weg und setzt im requestAnimationFrame Cursor und Fokus. Sie wird heute schon von AppShell.tsx und useAgentSession.ts benutzt. Ihr Koerper wurde nur in eine useCallback gezogen, die Handle und JSX gemeinsam nutzen; der Aufruferpfad fuer @-Erwaehnungen ist unveraendert. Ohne Phrasen wird nichts gerendert, kein leerer Container und keine Trennlinie. Umbruch ist verboten, der waagerechte Scroll haelt jeden Knopf mit einem Klick erreichbar, auch bei zwanzig Phrasen auf 390px. Auf dem Handy faellt die Scrollleiste zugunsten des Finger-Scrolls weg. Zwei Felder pro Phrase, weil der Knopf das Label zeigt und der eingefuegte Text etwas voellig anderes sein kann. Ein leeres Label ist erlaubt und faellt auf den gekuerzten Text zurueck, ein leerer Text verwirft die Phrase — sie sondert keinen Knopf ohne Wirkung ab. i18n-Schluesselparitaet wird im Projekt nirgends geprueft, ein Tippfehler faellt als Schluesseltext im UI auf. Beide Sprachdateien sind im selben Zug geaendert.
Bis hierher lagen die Phrasen im localStorage. Das ist ein Speicher pro
Browser, pro Programm, pro Benutzer des Betriebssystems: ein geteilter Rechner
oder ein zweites Fenster mit privatem Zustand zeigt zwei Listen, und beim
Browserwechsel sind die Phrasen weg, ohne dass der Nutzer etwas getan hat.
Neue Route app/api/quick-phrases/route.ts, nach dem Muster von
app/api/models-config: requireIdentity mit 403 in GET und PUT, hasJsonContentType
mit 415, atomisches Schreiben ueber writePrivateFileAtomicSync. Eine Datei pro
Konto, omp-web-quick-phrases.json unter resolveTenantAgentDir. Der Pfad wird
gegen getUserHome geprueft, BEVOR ein Verzeichnis entsteht — ein Pfad, der erst
erzeugt und dann geprueft wird, hinterlaesst bei jeder verweigerten Anfrage eine
Datei in fremder Home.
Drei Fehler, die erst das Messen sichtbar gemacht hat:
Die Datei las sich immer leer. Sie ist ein Wrapper um {phrases: [...]}, der
Validator bekam aber das Wrapper-Objekt und lieferte konstant [].
Zwei gleichzeitige PUTs, die alte Antwort gewann die Race. "Add phrase"
schrieb sofort die leere Zeile als [], danach kam das Fuellen; die Antwort auf
den aelteren Request kam als letzte an und schrieb die Datei wieder auf [].
Geloest mit einer Kette, die die Schreibvorgaenge nacheinander absetzt, und
einem Zaehler, der eine veraltete Antwort am Zuruecksetzen des Zustands hindert.
Beides ist noetig: die Kette sortiert die Datei, der Zaehler schuetzt den
Browserzustand.
Der Flush am Fensterende las aus dem Store statt aus der Komponentenliste. Der
Store verwirft per Definition jede Zeile ohne Text, und eine frisch angelegte,
unausgefuellte Zeile ist genau eine. Der Flush schrieb damit eine Liste, die
die Zeile nicht enthielt. Er nimmt die Liste jetzt als Parameter entgegen.
Bestand migriert, nicht ersetzt: existiert die Datei nicht, wandern die
localStorage-Phrasen beim ersten Abruf rüber. Sonst löscht der Umstieg die
Phrasen ohne Vorwarnung. Gesaet wird nur, wenn es nichts zu retten gibt.
Spiegel im localStorage: liest den zuletzt erfolgreich bestaetigten Stand, damit
ein Serverausfall die Oberflaeche nicht leerzeigt. Er entscheidet nie ueber das
Saeen, die Datei gewinnt sobald sie antwortet, und wird nie als Schreibziel
genutzt. Ohne ihn waere nach einem Ausfall und Reload keine einzige Phrase zu
sehen.
Getippt wird entprellt: 20 Zeichen ergaben 0 Requests waehrend des Tippens und
einen danach. Delete schreibt sofort, sonst glaubt der Nutzer, das Geloeschte
komme wieder.
Attaching several full-resolution photos in one message failed with a bare 413 and no explanation. Two independent causes, both addressed: The browser read each picked file into a data URL at full resolution, so a 12 MP phone photo contributed ~3 MB. Three of them are 8.1 MB of JPEG but 11.2 MB once base64-encoded, which is what the transport actually counts. ChatInput now downscales attachments before they go on the wire, targeting a 2048 px longest edge and 2 MB per image — the same normalization the CLI already applies, so both surfaces send comparable payloads. Measured against the three photos from the report, the request body drops from 10.74 MB to 2.16 MB. The 10 images x 10 MB allowance was also unreachable in principle. `proxy.ts` matches /api/:path*, so Next applied its default 10 MB body limit and refused the request before any route handler ran. The proxy limit is now sized to the server-side budget, and a Content-Length guard in the route answers with the `prompt_rejected` shape the client already handles instead of letting the proxy drop the connection. Attachments are also no longer dropped silently when they do not fit: the composer keeps what it can and the request is rejected with a reason a user can act on. Verified in Chromium against the original files: 3072x4096 to 1536x2048 with a mean absolute pixel difference of 1.14-2.10 out of 255.
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
Author
|
Closing in favour of a branch rebased on |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes image attachments that overflow the request body
Attaching several full-resolution photos in one message failed with a bare
413and no explanation. There are two independent causes, and this addresses both.Cause 1 — the client never resized anything
ChatInput.tsxread each picked file into a data URL at full resolution, so a 12 MP phone photo contributed ~3 MB to the request. Three of them are 8.1 MB of JPEG but 11.2 MB once base64-encoded — and base64 is what the transport actually counts.The CLI never had this problem: the harness normalizes its own attachments to a 2048 px longest side before sending. The browser path had no equivalent, which is why the same files worked in the terminal and not in chat.
ChatInputnow downscales attachments before they go on the wire, targeting a 2048 px longest edge and 2 MB per image — the same normalization the CLI applies, so both surfaces send comparable payloads. An attachment that is already small enough is left untouched, and a platform without canvas falls back to today's behavior rather than rejecting an image the user can see working elsewhere.Cause 2 — the documented allowance was unreachable in principle
proxy.tsmatches/api/:path*, so Next applied its default 10 MB body limit and refused the request before any route handler ran. The advertised allowance of 10 images × 10 MB could never actually be sent, since ten 10 MB attachments serialize to ~133 MB of base64.next.config.tsnow setsexperimental.proxyClientMaxBodySize, sized to the server-side budget rather than left at the framework default.Content-Lengthguard in the route answers with theprompt_rejectedshape the client already knows how to react to, instead of letting the proxy drop the connection with no reason.validateAgentImagesgained an aggregate check, so a message that is over budget is refused with an API-safe message rather than by a layer that cannot explain itself.Attachments are also no longer dropped silently when they do not fit: the composer keeps what it can, and a genuinely over-budget message now fails with something a user can act on.
Verification
Measured in Chromium against the three original phone photos:
Request body 10.74 MB → 2.16 MB (80% reduction), which fits even the old 10 MB default — so the fix does not depend on the config change to work.
Quality holds up: 3072×4096 → 1536×2048 with a mean absolute pixel difference of 1.14–2.10 out of 255, i.e. visually lossless.
Gates:
bun test(660 pass; the 10 pre-existing auth/network failures are unchanged frommain),tsc --noEmitclean,eslintclean on every touched file.Notes
lib/image-attachments.tsstays the single source of truth for the budgets;next.config.tscannot import it, so a test asserts the configured proxy limit covers the base64 expansion of the aggregate budget.