d2f436069d0417623d88d214e5c7cd0bb00896a3
9
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d2f436069d |
fix(mcp): rebuild the prompt prefix when the connector set changes
Nightly Build / build (push) Successful in 8m14s
The `## MCP servers` table lives inside the frozen system prefix, which PrefixCache holds for twenty idle minutes. Refreshing a user's global-access snapshot fixed what `mcp.tools()` offers but left the table describing the world before the change, so an admin could enable a connector, ask for it in an open conversation, and be told in good faith that it does not exist — with the tools sitting right there. Same gap on a reinstall, whose new llm_short_description reached the runtime and not the prompt. Both refreshes now call `invalidate_prefixes()` on the live contexts they were already iterating, the seam the skill tools use. Order matters and runs against the intuition: `render_mcp_list` renders the runtime's in-RAM state, not the DB, so the invalidation goes last — after the snapshot refresh and after the servers restart. Rebuild earlier and the prefix is repopulated from the very descriptions being replaced, with nothing left to invalidate it a second time. In the reinstall that means waiting out a dependency install; those users were already reading a stale table, and an early rebuild would only freeze the stale one in place. Also warn when a feed's connector.json and index disagree on the integer version. The manifest silently wins, so if the index is the lower of the two the strict `feed > installed` comparison is false forever: the connector never offers an Update and nothing anywhere says why. |
||
|
|
59549d2b3b |
fix(mcp): install and expose a global connector's deps where they are needed
Nightly Build / build (push) Successful in 8m7s
Two halves of the same failure, found debugging a marketplace connector that logged "connected — 6 tool(s)" while every call died on a missing module. The verify ran as a bare `sh -c` and inherited nothing, so a python connector was rejected by its own verify for a dependency installed one directory away — `global_enable` installs before it verifies, so the deps were provably there at the moment the check denied them, and the row ended up disabled. Only connectors that bother to declare a verify could hit it. `verify_env` now builds the verify's environment in one place and derives PYTHONPATH from the workdir, which is already the connector dir in both targets; `or_insert`, so a value the form declares still wins. The global branch of the reinstall refresh restarted the server without ever installing its deps: `ensure_installed_host` was reachable from `global_enable` alone, so a marketplace Update that adds a requirements.txt landed the file and brought the connector back exactly as broken. It now runs once per connector folder before the restart loop, best-effort. The per-user branch had always reinstalled, which is why nothing with scope=user ever showed the bug. Known gap, deliberate: POST /api/mcp/test shares run_verify but not the install, so testing a python connector never enabled on the box still fails on missing deps. Making a "try it" button write to disk for minutes is the worse trade. |
||
|
|
5fb5854ff2 |
fix(auth): stop the re-login dialog hijacking the login screen
Nightly Build / build (push) Successful in 8m11s
On a cold load with no session, both shells mount every component before their boot auth check resolves, so a dozen gated /api calls 401 in parallel and the fetch watch raised the re-login dialog over the login screen the boot check was about to show (.relogin-backdrop is z-10000, .login-page z-9999). The user typed their password into the modal, which only closes itself on success — revealing the login page still up with the app hidden, so they were asked a second time and only a manual reload got them in. The dialog is for a session that dies under an open tab, so gate it on one having ever been established. Recognising that is passive, in the same fetch wrapper: mobile.html probes /api/auth/me from a classic inline script that runs before this module exists, so an explicit marker per shell would never fire there and the dialog would be dead on mobile. Any 2xx from a gated endpoint proves a session; only the routes guard.rs::is_public lets through unauthenticated are excluded. Knock-on: with the report now a no-op on a cold load, the chat's reconnect loop no longer stopped on it. Retry only in the native shell, which authenticates on its own — everywhere else something is already asking for a password. |
||
|
|
5980bdb5b9 |
feat(users): unlock and start unencrypted users at boot
Nightly Build / build (push) Successful in 8m10s
A login is what makes an *encrypted* database readable; for an
unencrypted one it gated nothing but the runtime — the file has no key
and is already readable by this process. The cost was user-visible and
read as a bug: after every restart the Telegram bot answered "your
account is locked, log in via the web app", cron fired nothing and no
background agent ran, until a human opened the SPA.
`Skald::new` now calls `UserManager::unlock_all_unencrypted`, which
registers the pools exactly as a login would and refuses an encrypted or
inactive user. Unlocking alone only makes the data readable, so
`wiring::spawn_unlocked_user_runtimes` then builds a `UserContext` for
each — cron, the notify queue, the hub and the per-user MCP runtime all
hang off it. That build is a background supervisor task rather than part
of `new()`: it starts every member's MCP servers inside their container,
and the HTTP listener must not wait behind it. The same two steps run
per user off the lifecycle bus (`UserCreated`,
`UserActiveChanged{active:true}`, after the container `ensure`), so a
member created at runtime does not wait for the next restart.
Two boundaries stay where they were. Authentication is untouched:
`SessionStore` sits above `UserManager`, so no HTTP request
authenticates as anyone because of this. And the auto-unlock is
deliberately not on a lazy path such as `Skald::user_context` —
`revoke_user_runtime` locks a pool synchronously and expects nothing to
re-open it, so the writers of that map stay boot, login and the bus.
`open_db` and the two unencrypted openers now share `register_unlocked`
and `open_unencrypted_file`; `open_unencrypted` (the supervision path)
still does not register its pool.
|
||
|
|
55dcb48299 |
fix(telegram): resolve send_attachment paths in the user's workspace
Nightly Build / build (push) Successful in 8m6s
`send_attachment` handed its `file_path` argument straight to `InputFile::file`, which resolves against the **server process's** working directory. Every path the model can actually have — relative to the user's home, or absolute inside their container — failed the `path.exists()` check, and the one class that didn't (a name that happens to exist next to the binary) would have sent the wrong file. The routing already exists for the fs-tools, so expose it rather than repeat it: `UserFilesApi` (core-api) reads a path in the agent's own vocabulary and is obtained from `UserChannelHandle::files()`, so it is scoped to one user by construction. skald-core implements it over `resolve_view_target` — host mount read directly, container-only path through `docker exec` — holding the `SharedFs` cell rather than a snapshot, so a remount lands without a login. The size cap is checked before the read (a new `exec_fs::size` for the container branch): the point of a cap is to keep an oversized file out of RAM, so checking it afterwards would protect nothing. A photo above `sendPhoto`'s narrower 10 MB ceiling goes out as a document instead of as an API error. |
||
|
|
5765941758 |
feat(prompt): tell the agent what its sandbox can run
Nightly Build / build (push) Successful in 8m6s
The agent had no way to know its container ships ffmpeg, ripgrep or tesseract, so it either declined work it could do or spent a round finding out. This adds a command list to the system prompt as a **discovery hint** — explicitly not an inventory. Every decision follows from it being a hint: - The allowlist (~35 entries, `container/commands.rs`) is the curation; a full PATH dump is 800 entries of coreutils noise. The probe exists so the list cannot *lie*, not so it can discover: `command -v` at login means we never announce something a container recreate threw away. - The rendered prose says the list is partial and names `command -v`, so a tool outside the allowlist costs one check rather than a wrong conclusion. An empty probe renders as an explicit "could not be read", never as silence under a heading promising a list. - Order is the allowlist's own, grouped by kind of work — the grouping is the curation, and the reader is a model, not a grep. - Staleness is cheap both ways, so there is no invalidation machinery: a login-time snapshot on `UserContext`, non-fatal, refreshed at next login. The gate is the tool, not the sentinel. Every AGENT.md carries `common/sandbox.md` — the four system agents included — and the section is emitted iff the turn's model is shown `execute_cmd`, derived from `allow_tools` plus the security group's visibility filter for a root turn and from `child_defs` for a sub-agent: always the same definitions the model will see. `has_execute_cmd` therefore joins the PrefixCache key, since the group is switchable mid-conversation and that switch already rewrites the tool payload in the same provider cache. The fragment holds only the heading and one stable sentence; every conditional claim lives in the renderer, because prose promising `sudo apt-get install` is not the renderer's to retract when the tool is absent. `execute_cmd`'s own description loses `(python + node available)`: its job is steering away from the shell, and a capability advertisement diluted it. |
||
|
|
c27da4e6ab |
feat(skills): rebuild the skill system for the multi-user model
Nightly Build / build (push) Successful in 8m6s
Per blueprint/skill-project.md: the old single-namespace, hand-maintained
index is gone, replaced by a read-only, two-scope tree whose index is a
runtime function of its content.
- skills/ index generated at runtime (crates/skald-core/src/skills/:
inventory, install, validate, watch), injected through the new
<!-- SKILLS_LIST --> placeholder in AGENT.md (agents/common/skills.md);
meta.json inject_skills flag removed. 11 chat/task agents carry the
include, the 4 system agents do not.
- Two trees, both read-only in both directions: skills/shared/{id} (the
group's) and skills/{username}/{id} (one member's own, on the stable
userid). The root is closed too: UserFs::SkillMounts + RouteError (alias
probe, plain-denied paths, no home fallback) and a per-user
.skills-root/{userid} container mount with the two scope mounts nested
inside, plus the fifth self-heal axis (skills_mounted).
- Agent verbs: skill_register/skill_delete (Config group, global scope
behind the new skill.manage capability), fetch_repo for public repos,
list_items(type="skills"); reads are plain read_file on the printed
path. Seeded @fs_read skills/* allow.
- Freshness: a digest-gated watcher on the two trees emits
SystemEvent::SkillsChanged, whose subscriber rebuilds the frozen prompt
prefix via Skald::invalidate_prompt_prefix; in-process writers invalidate
directly.
- The build ships no skills: the three bundled skills (ics2json,
mcp-builder, skill-creator) and skills/index.md are removed, skills/ is
instance data (gitignored, not packaged, no longer pruned by update.sh).
- Docs: skills.md, agents.md, shared-folders.md added; docs/index.md and
agents/README.md updated.
|
||
|
|
71e1a26b08 |
fix(ui): render PDFs with pdf.js instead of an iframe
Nightly Build / build (push) Successful in 7m59s
On iOS the file viewer showed only the first page of a PDF, with no way to scroll to the rest — the document had to be downloaded and opened in another app. The cause was not ours: WebKit refuses to mount its PDF viewer inside an <iframe>/<object>/<embed> and paints a static first-page thumbnail instead. That hits Safari on iOS and every WKWebView, so the native shell too. The full viewer only exists for a top-level navigation. The desktop browsers do mount a viewer, but each mounts its own — Chrome's toolbar, Safari's page-index sidebar — so the same document also looked different on every machine. Both are answered by drawing the pages ourselves. New <pdf-view> (web/components/shared/pdf-view.js) renders a continuous scroll of canvas pages on the vendored pdf.js, with a zoom control and a page counter, and replaces the iframe for both native .pdf files and server-compiled LaTeX. Three properties are load-bearing: - pdf.js is imported lazily (~450 KB + a 1.2 MB worker), so a session that never opens a PDF never pays for it. - Canvases are created and destroyed as pages scroll. iOS caps the total canvas backing store a page may hold and silently blanks canvases past it, so an eager render would come out empty on exactly the platform this was written for. Off-screen pages keep only a correctly-sized box, which is also what keeps the scrollbar honest. - The text layer (selection, in-page find) is best-effort: it is transparent DOM over the pixels, so its failures are swallowed rather than surfaced. Vendored from pdfjs-dist 6.2.108: pdf.min.mjs, pdf.worker.min.mjs, the standard-font data (needed by PDFs that reference Helvetica/Times without embedding them) and the .textLayer block of pdf_viewer.css. CJK cmaps are deliberately left out. pdf.js 6 needs Safari/iOS 17.4+. Verified in headless Chromium against both a synthetic 12-page PDF using non-embedded Helvetica and a real 14-page paper with embedded fonts and figures: all pages present, last page renders after scrolling, page 1 released off-screen, text layer populated, zoom re-renders at the new scale, no JS errors. |
||
|
|
3744884070 |
fix(relay): detect a silently dead agent WebSocket and redial
Nightly Build / build (push) Successful in 7m53s
The agent's control WS to the relay was purely reactive: it answered the relay's Ping with a Pong and otherwise never wrote anything for long stretches. So when the path broke silently — NAT rebinding, a reverse proxy dropping its state — there were no unacked bytes for the kernel to retransmit, the socket never errored, and the relay's Close (it gives up after 120s of quiet) fell into the same hole. stream.next() then parked forever on a socket to nobody, is_connected() kept answering true, and the reconnect schedule below it — which works fine, it just never got asked — was never reached. Only a process restart cleared it. Relay logs show the cost: three idle-timeout closes of the agent connection with the agent absent for 2h, 9h and >2h afterwards, while every disconnect it *did* notice was back in 2-4 seconds. During one of those windows the iOS client authenticated four times and not a single pipe matched: pipe_invite rides the E2E channel through the agent's WS, so with the agent gone the web view had nothing to tunnel through. Add a per-session liveness probe. Both halves matter: a WS Ping every 20s keeps unacked bytes on the wire so a dead path finally surfaces as a TCP error (and the relay's Pong refreshes its own idle timer), and 75s of inbound silence — two missed relay pings — returns Err, handing the session to the existing backoff schedule. Covered by a test against a relay that completes the v2 handshake and then goes mute, the shape a black-holed path leaves behind. It reads the raw TCP stream rather than ws.next() because tungstenite auto-answers a Ping with a Pong on the next read, which would keep last_seen fresh and defeat the silence being simulated. Without the probe the test hangs instead of redialing. |