Skip to content

MCP for other agents, a terminal that stays sandboxed, and a real mobile pass - #58

Merged
praxagent merged 11 commits into
mainfrom
wip
Jul 28, 2026
Merged

MCP for other agents, a terminal that stays sandboxed, and a real mobile pass#58
praxagent merged 11 commits into
mainfrom
wip

Conversation

@praxagent

Copy link
Copy Markdown
Owner

A day of work on one branch. Grouped by theme.

Security: the terminal handed out a host shell

Reported from a live session — it announced "Connecting to sandbox…" and dropped you on the host.

_spawn_terminal_session ended in a branch labelled "local fallback (dev mode without docker)" that spawned $SHELL with env={**os.environ, ...} — inheriting every credential the service holds, reachable by anyone who could open the panel. It triggered on the ordinary case of SANDBOX_CONTAINER being unset.

Worse: mode was a client-supplied query parameter selecting between docker-exec and that shell, so ?mode=local reached the host even on a correctly configured deployment. Where code runs is never the caller's to choose.

Both gone. No sandbox, no terminal. The test is structural — it parses the module and fails if any Popen names a shell or if **os.environ reappears, because a behavioural test only covers the branch you thought to exercise and this lived in the branch nobody did.

MCP — other agents can work in a space

Off by default behind three independent gates. Keys scoped to one space; a scoped key naming no space is refused, because an omission must never silently widen a narrow key. Writes are git-backed so a bad one is revertable.

Enabling is a button, not a hand-authored credential file — it mints a scoped key, writes the registry (0600, hash-only, atomic, preserving hand-authored entries) and shows the connect command once. Both Claude Code and Codex get instructions; naming a tool and then not saying how is worse than not mentioning it.

Each space also generates a paste-ready agent skill teaching what belongs on a human's board — the agent's plan for the next ten minutes is working memory, and working memory does not go on someone else's wall.

Mobile — swept, not patched

Most users will be on phones, so this went after patterns rather than reported panels:

Pattern Found
Flex column clips instead of scrolling () 7 panels
Fixed sidebar squeezing content to a sliver 3
Popover wider than the screen 5
Inline desktop width leaking to mobile the dead band beside the traces

Plus: long strings now wrap (the actual cause of sideways drift — I first suppressed the gesture, which broke Kanban scrolling and was treating a symptom), the tab bar hides while the keyboard is up instead of covering the composer, previews are viewport-relative so a PDF can't bury the chat box, and Back walks the panels instead of ejecting you to the project picker.

Two structural guards fail the build if the inline-width or missing-min-h-0 patterns return.

Also

Trace dates (list and detail header, which had no timestamp at all); model badge on messages; resizable per-space chat; .gitignore now covers every .env derivative — a dated backup showed as untracked in a public repo and only had to meet one git add -A.

298 backend + 39 frontend tests green. Deployed and in use on the live box throughout.

Not verified on a device — there's no touch hardware here, so the mobile work is correct-by-construction, not seen-working.

praxagent added 11 commits July 27, 2026 18:29
…ng it as 403

The desktop panel failed with a 403 on the clipboard websocket and a 503 on the
noVNC assets. Both had the same cause and both reported it badly.

DESKTOP_VNC_URL was never a setting. main.py read
getattr(settings, 'desktop_vnc_url', None) or os.environ[...] — and since the
field did not exist on the model, the getattr always returned None. It was in
neither config.py nor .env.example, so the only way to learn the variable was
required was to read the proxy handler. A setting you cannot discover is a
setting nobody sets. It is now a declared field, documented, with both correct
values for the two deployment shapes.

The 403 was worse than useless. The handler closed the websocket WITHOUT
accepting it first, and Starlette turns that into a bare HTTP 403 — so the
browser reported 'Forbidden' for what was really 'nobody configured the
desktop', and anyone debugging it goes to check tokens and tailnet ACLs, which
are fine. It now accepts first so the close reason reaches the client, and says
what to set.

The 503 on the asset proxy now carries a fix, not just a complaint: the message
names the variable, a working value, and the fact that the sandbox has to be
running. That text appears in a browser console, where 'not configured' alone
sends people into the UI code.

294 tests green.
Reported from a live session: the terminal announced 'Connecting to sandbox...'
and dropped the user on a shell on the HOST.

_spawn_terminal_session ended with a branch labelled 'local fallback (dev mode
without docker)' that spawned $SHELL on the machine hosting TeamWork with
env={**os.environ, ...} — so that shell inherited every credential the service
holds, and anyone who could open the panel could open it. It triggered on the
ordinary case of SANDBOX_CONTAINER being unset, so a deployment that had simply
not configured a sandbox handed out a host shell instead of a sandboxed one.

Worse: `mode` was a client-supplied query parameter selecting between
docker-exec and that local shell. ?mode=local reached the host path even on a
deployment with a sandbox correctly configured and running. Where code runs is
never the caller's to choose.

Both are gone. No sandbox, no terminal — a terminal outside the sandbox is not a
degraded terminal, it is a different and much more dangerous thing, so the
honest answer when the sandbox is missing is to refuse and say why. `mode` is
accepted for URL compatibility and ignored; it no longer reaches the spawn path.

The main test is structural: it parses the module and fails if any Popen names a
shell directly, or if `**os.environ` or $SHELL reappear anywhere. A behavioural
test only covers the branch you thought to exercise, and this lived in the
branch nobody did.

298 tests green.
The trace list showed clock time only, which is ambiguous the moment the list
holds more than one day of runs — '14:32' does not tell you whether that was an
hour ago or last Tuesday, and the list is exactly where you go to find a run you
remember by when it happened.

Today's runs keep a 'Today' prefix so the common case stays scannable rather
than becoming a wall of identical dates, and the full locale timestamp is in the
title attribute.

The detail header had no timestamp at all. It named the trace and its source and
never said when it ran, so the single question you open an old trace to answer
was the one thing that view could not tell you.

Span times inside a trace stay clock-only: they sit under a header that now
carries the date, and repeating it per node is noise.
On mobile, dragging up and dragging down did the same thing — both pulled the
view down instead of scrolling. There are no touch handlers in the app, so
nothing had inverted a gesture: the DOCUMENT was scrolling instead of the pane.

#root asks for min-height:100vh, and on iOS 100vh is the LARGE viewport — the
height as if the URL bar were hidden. So the page is taller than the visible
area even when nothing overflows, and the workspace shell (which manages its own
overflow) sits inside a scrollable document. A drag moved the document, and a
document with almost nothing to scroll does not scroll, it rubber-bands. Both
directions were a pull; neither was a scroll.

Only overscroll-behavior-x was set, so nothing forbade the vertical bounce.

The workspace now adds an app-shell class to <html> while mounted, pinning the
document to the visible height and disallowing vertical overscroll — the gesture
has nowhere to go except the pane that should have had it. Scoped to this route
so the lander and project list keep normal page scrolling.

NOT verified on a real device: this is mobile-Safari viewport behaviour and
there is no touch device here. The inner containers are already correctly
bounded (flex-1 min-h-0 overflow-y-auto), so pinning the document is the piece
that was missing.

37 frontend tests green, tsc clean.
…anes scroll

Two bugs in the trace panel on mobile, both instances of patterns worth sweeping
for across the app.

A BAND OF EMPTY SPACE TO THE RIGHT

The resizable column width was applied as an inline style. Inline styles apply
at every breakpoint and outrank Tailwind, so `w-full` never won and a phone got
the desktop resizer's pixel width — a column wider than the screen, with
`shrink-0` stopping it from adapting. It is now handed to CSS as a variable that
only the md: class consumes, so mobile is genuinely full-width.

THE TOP CUT OFF, WITH NO WAY TO SCROLL TO IT

The columns are flex children without min-h-0. A flex child defaults to
min-height:auto and so refuses to shrink below its content; combined with
overflow-hidden the surplus is CLIPPED rather than scrolled, and the inner
overflow-auto never gets a bounded parent to scroll within.

This was survivable before the document was pinned — you could drag the page
itself past the clipped region — so pinning the document (correct on its own)
turned a bad scroll into no scroll. Adding min-h-0 lets the column shrink so the
inner scroller does its job.

37 frontend tests green, tsc clean. Neither is verified on a real device.
Two mistakes produced three separate 'mobile is broken' reports, so this fixes
the patterns rather than the panels that happened to be noticed.

FLEX COLUMNS THAT CLIP INSTEAD OF SCROLLING

A flex child defaults to min-height:auto, so it will not shrink below its
content; with overflow-hidden the surplus is clipped and the inner scroller
never gets a bounded parent. Seven more panels had it — Desktop, Terminal,
Browser, Progress, TaskBoard, LibrarySpaceView, LibraryPanel — each one a
trace-detail-shaped bug waiting for the right content to trigger it.

FIXED-WIDTH SIDEBARS IN A HORIZONTAL ROW

w-56 / w-64 / w-72 beside a content pane leaves that pane a sliver on a 390px
screen. Observability, LiveSessions and Memory now stack: a full-width band
above the content, and the row only forms at md.

POPOVERS WIDER THAN THE SCREEN

ModelPicker, EmojiPicker, ClaudeCodeStatus, the slash-command menu and Memory's
tooltip all carried fixed widths that push the page wide on a phone. Each is
capped to min(width, 100vw - 2rem), unchanged anywhere roomy.

TAP LATENCY AND TARGETS

touch-action: manipulation on controls drops the double-tap-to-zoom gesture and
with it the ~300ms wait before a tap registers — the app felt sluggish for no
reason other than the browser watching for a second tap. Layout-neutral, so it
cannot regress anything visually. The chat action row (the most-tapped surface)
goes from ~24px to 40px on phones and is unchanged at md.

TWO STRUCTURAL GUARDS

frontend/src/__tests__/mobile-layout.test.ts fails the build if an inline pixel
width or a min-h-0-less clipping column reappears. They pass now, which is how I
know the sweep was complete rather than merely thorough-feeling. They assert
against source because jsdom has no layout engine and cannot tell you something
is off-screen.

NOT verified on a device — there is no touch hardware here. Deliberately left:
the other ~38 small icon buttons (a blanket resize risks dense layouts I cannot
see) and any information-architecture change, e.g. trace detail still hides its
header behind hidden md:block.

39 frontend tests, 298 backend tests green.
…ways

BACK LEFT THE APP

The workspace persisted which panel you were on to localStorage — which
survives a reload but is invisible to the browser. There was exactly one
history entry for /project/:id, so Back from six panels deep landed on the
project picker, losing the whole path you took to get there. On a phone Back is
THE navigation gesture, so this was the app losing an argument with the platform.

Each view change now pushes an entry and popstate restores it, including the
graph runs -> nodes -> detail drill, so Back walks back the way you came.

The subtlety is not rebuilding the stack while unwinding it: applying a popstate
must not itself push, or Back appears to do nothing. A guard flag covers that,
released on the next frame so the push effect sees it.

Deliberately not in the URL. These are panel toggles rather than addressable
locations, and inventing a query-param vocabulary for each would commit us to
URLs we then have to keep working.

SCROLLS DRIFTED SIDEWAYS

A finger never travels exactly straight, and the browser was reading the slight
sideways component of a vertical scroll as a horizontal pan — small, constant,
and enough to make reading feel unsteady. Vertical scrollers now declare
touch-action: pan-y.

Scoped carefully: touch-action on an ancestor constrains its descendants, and
chat messages carry wide code blocks, tables and mermaid diagrams that must keep
panning sideways. The rule covers .overflow-y-auto only and explicitly releases
.overflow-x-auto and .overflow-auto, so reachability is not traded for a wobble.

39 frontend tests green, tsc clean. Not verified on a device.
A dated backup made during an incident — cp .env .env.bak-$(date +%s) — is a
full set of live credentials sitting in the working tree, and it showed up as
untracked here. It only has to meet one `git add -A` once, in a PUBLIC repo.

Prax already ignored .env.bak*; this repo did not, so the identical habit was
safe in one repo and a leak in the other. That asymmetry is the actual bug: the
person doing it has no way to know which repo they are in.

Also covers .env.backup*, .env.save and .env.orig — the other names editors and
scripts reach for.

The two backups on the live box have been moved out of the repos entirely, to
~/.prax-env-backups (0600), which is where they should have been written.
CHAT SLID SIDEWAYS — the real cause, not the gesture

Nothing in the app breaks long strings. A pasted URL has no break opportunity,
so it forces a message wider than the screen; and because CSS computes
overflow-x to auto whenever the other axis is not visible, the overflow-y-auto
message list quietly becomes horizontally scrollable the moment that happens.

The earlier touch-action: pan-y suppressed the pan but left the content
unreachable off-screen — treating the symptom. Long strings now wrap
(overflow-wrap: anywhere), so there is nothing to pan TO. Scoped to prose and
message text; tables and code blocks keep their own horizontal scrolling.

THE TRACE SUMMARY WAS A ONE-LINE SLIVER

Reported with a screenshot: the summary compressed until not even a full letter
showed, under a full-height list of 21 span names, and scrolling did nothing
because the section had no height to scroll.

An overflow-auto flex item has an automatic minimum size of ZERO, so the summary
was the first thing crushed when height ran short. The Delegated Spans list
below it has visible overflow, so its minimum is its content height and it
refuses to shrink at all. The summary lost a fight it could not win.

On a phone the detail pane is now one scrolling column — sections at natural
height, the column scrolls. Summary and spans are both shrink-0 so neither can
crush the other; live output gets a real minimum instead of filling height the
column no longer allocates. The desktop three-pane layout is unchanged at md.

Same family as the earlier min-h-0 bugs, opposite direction — which is why the
structural guard did not catch it. That guard tests what I knew to look for.

39 frontend tests green, tsc clean.
…omposer

THE KANBAN ONLY PANNED IN ITS TOP THIRD — my bug

touch-action: pan-y constrains every DESCENDANT, not just the element it is set
on. So horizontal panning survived only where a finger landed on the scroller
itself and died everywhere below it. Suppressing a gesture app-wide to fix one
surface takes it away from the surfaces that need it.

Removed entirely rather than narrowed. The chat drift it was added for is now
fixed at its source — long strings wrap, so nothing is wider than the screen and
there is nothing to drift toward. A comment marks the rule as one not to
reintroduce, since the next person will meet the same wobble and reach for the
same tool.

CODEX WAS NAMED BUT NOT SERVED

The panel says "Claude Code, Codex or another harness" and then offered a
claude-mcp-add line, which is useless to Codex. Telling someone their tool works
and not saying how is worse than not mentioning it.

Codex is configured by file (~/.codex/config.toml) and speaks stdio, so an HTTP
server is reached through the mcp-remote bridge. Both forms are now shown and
labelled, and the API returns both.

A PDF PREVIEW COST YOU THE CHAT BOX

The preview iframe was a fixed 600px — taller than most phone viewports — so
opening a file pushed the composer and its send button off the bottom, and the
space stayed unusable until you closed the file. Previews are now capped against
the viewport (55vh for PDFs, 50vh for image and video), unchanged on desktop. A
preview should never cost you the ability to type.

3 new tests; 301 backend + 39 frontend green, tsc clean.
…ling off

THE TAB BAR SAT ON TOP OF WHAT YOU WERE TYPING

The mobile tab bar is fixed bottom-0, which anchors it to the LAYOUT viewport —
and that does not shrink when the keyboard opens. So it covered the composer and
you could not see your own text.

App.tsx now detects the keyboard from the gap between the layout and visual
viewports (150px: comfortably above the browser chrome that also comes and goes,
comfortably below any real keyboard) and marks it on <html>. The bar hides while
typing and the padding reserved for it is reclaimed at the same moment, so no
strip of dead space is left behind. It returns when the field is dismissed.
Navigation you cannot use mid-sentence is worth less than seeing your sentence.

THE SPACE CHAT WAS A FIXED 320px

Right for a glance, wrong whenever the chat IS the work. It now drags between
280 and 880px and the width persists per space, because how much room a
conversation deserves is a property of what you are doing there.

The width is handed to CSS as a variable consumed only at md:. An inline width
applies at every breakpoint and outranks w-full — the exact bug that put a dead
band beside the trace panel — and the mobile-layout guard caught me doing it
again with the drag handle, which is what a guard is for.

vh IS THE WRONG UNIT ON iOS

The mobile chat pane was h-[40vh]. On iOS vh is the LARGE viewport — the height
as if browser chrome were hidden — so the pane is taller than 40% of what you
can actually see, and with shrink-0 it can be pushed past the bottom edge. Now
45svh with a floor: svh is the viewport that is always really there.

This may or may not be the reported "no chat on mobile"; the pane is otherwise
structurally sound and I could not reproduce the cause without a device.

39 frontend tests green, tsc clean.
@praxagent
praxagent merged commit eb8c09b into main Jul 28, 2026
1 check passed
@praxagent
praxagent deleted the wip branch July 28, 2026 01:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant