Commit Graph
60 Commits
Author SHA1 Message Date
dguiducci 78cdcf4cc7 feat: let one source carry several chats, and open them with a +
Nightly Build / build (push) Successful in 7m49s
A source had exactly one live conversation, so the copilot could only ever
replace a chat, never add one: the trash button reset the source and the old
conversation was left orphaned. Working on two things at once meant losing one.

The tab bar now holds two kinds of tab. A primary tab is a source — it shows
whatever `web` or `project-7` currently points at, which is where background
delivery lands (notify, a finished async task, an inbound Telegram message) and
what a reset moves to a fresh row. A secondary tab, opened with `+`, is one
specific conversation: its source points elsewhere, so it is unreachable by
source name and is addressed by id throughout — REST, WebSocket, event
filtering. `POST /api/sessions/new` creates one without touching `sources`,
which is the whole difference from a reset; its agent and run-context still come
from the source, so an extra project tab is the coordinator with the project's
context. Project "Open chat" is untouched and still resumes the project's own.

The load-bearing half is in ChatHub: the input queue and the model pin are now
keyed by session, not by source. Two tabs on one source would otherwise
serialize into a single queue and a single turn, and share a `/model` pin — the
odd one out, since the security group was already per-session and persisted. The
source-taking methods survive as one-line resolvers, so Telegram, mobile and
cron are untouched. Because queues now grow with conversations rather than with
the handful of sources, a reset retires the queue it replaces instead of leaving
a consumer task parked forever.

Events are filtered per conversation, so anything a chat must see has to carry a
session id: `show_file_to_user`'s OpenFile and the security-group revalidation
were emitting untagged and would have reached nobody. A primary connection
additionally follows NewSession for its source, so a second window does not keep
talking to a conversation another window just reset.

Tabs can be renamed by double-click — `chat_sessions.title` existed and was dead
until now. An empty name stores NULL, so the box is also the undo.
2026-08-04 22:15:20 +01:00
dguiducci 8f5c5382c8 feat: keep the chat tabs you left open, and keep them with you
Nightly Build / build (push) Successful in 7m39s
Reopening the app closed every project tab: the copilot's tab bar lived in
RAM, so a reload dropped it and each conversation had to be found again from
its project board.

The set of open tabs is now a column on the session row, `chat_sessions.is_open`
(additive, `ensure_column`), restored by `GET /api/sessions/open` and written by
`PUT /api/sessions/{id}/open`. Not localStorage: that store is per-origin, so on
a shared laptop one member's tabs would greet the next, whereas the owner table
sits in their own encrypted file and follows them to another device. Which tab
is *selected* stays in sessionStorage — that one is genuinely per window, and a
shared value would have two windows fighting over it.

`is_open` defaults to 0 and `chat_sessions::create` never sets it: every `/new`
leaves its predecessor behind and every system-agent pass mints a row, so the
opposite default would restore a bar full of conversations nobody opened. Only
the copilot writes the column, at the moment it opens the tab. A reset moves the
flag rather than copying it — `POST /api/sessions` now returns the new id and
`new_session` carries it, and the old row is closed as the new one opens, or the
source would restore twice and a later close would clear the stale row.

Closing a tab clears the flag and nothing else: the conversation is kept and
comes back with its history when the project is reopened.
2026-08-04 21:50:10 +01:00
dguiducci 01b8a187b5 feat: let a background task ask the chat that started it, not just the Inbox
Nightly Build / build (push) Successful in 7m42s
An async sub-agent runs in a session of its own, so the rich per-session events
that draw the inline approval card never reach the chat's socket — only the
id-only inbox lifecycle ones do. A task blocked on an approval was therefore
invisible in the conversation that started it, and the only way to unblock it
was to notice the sidebar badge and go to the Inbox.

The chat already shows what it handed off. This asks the same question of the
pending items: `GET /{source}/inbox` joins them against the sessions of this
conversation's running async jobs, so "whose is this" has one answer, in the
same place `/{source}/tasks` answers it for a task. The client is left with a
list to render, not a correlation to guess. The live path adds no event — the
existing `approval_requested` / `clarification_*` broadcasts already reach every
socket of the user, and re-reading the endpoint turns a nudge into something
renderable and survives a reload for free.

The card sits above the task strip rather than in the transcript: the task that
is asking may have been started twenty messages ago, and a card that scrolls
away is a card that gets missed. One at a time, with a count of what is behind
it — a blocked task stays blocked whether or not its card is on screen, so
stacking them would trade a readable chat for a queue nobody asked to see. And
it closes: the ✕ hides the card without resolving anything, leaving the item in
the Inbox, because a panel that cannot be moved takes the chat hostage.

`InboxCardsMixin` is the cards and their resolve calls, split out of
`InboxMixin` so the chat and the Inbox render the same approval rather than two
drifting copies of it; `_afterInboxResolve` is the only thing they disagree on.

Elicitations are left out: `PendingElicitationInfo` carries no `session_id`, so
there is nothing to attribute one to a task with.

Also: an async task's context label said "CronJob:", which sends whoever reads
the approval looking on the wrong page — and now says so next to the task's
real name.
2026-08-04 21:00:45 +01:00
dguiducci 3f74dc26f2 fix: keep the session-detail page live, instead of freezing on a snapshot
Nightly Build / build (push) Successful in 7m49s
Leaving `#session/{id}` closes its watch socket, but coming back never
reopened it: the loader bailed out on an unchanged id, so the page showed
the transcript as it was when you left, with nothing streaming into it.
Reload whenever the socket is down, not only when the id changes.

The socket also had no keepalive, unlike the chat one — and a watched
session can go minutes without an event, which is exactly what an idle
proxy drops. Ping every 25s, and resync from the API on reconnect, since
the bus is a broadcast with no replay and everything sent during the gap
is gone.

Also: remove the duplicate `disconnectedCallback` that shadowed the first
and leaked the locale listener, handle `tool_cancelled`/`tool_rejected`
(a stopped or denied call stayed on "pending" forever), and follow the
tail only when the reader is already at the bottom.
2026-08-04 19:55:22 +01:00
dguiducci daaceff6ba feat: show a conversation its own background tasks, and give it back every outcome
Nightly Build / build (push) Successful in 7m34s
An `execute_task mode="async"` was invisible from the chat that started it.
The only trace was the receipt in the transcript and a row on the Tasks page
— which does not say *which* of those rows the assistant just spawned — so
"is it still going?" had no answer where the question is asked.

Worse, a task that did not simply succeed never came back at all. `run_job`
branched on `Ok`/`Err` first and routed by `job.kind` only inside the `Ok`
arm, so a failure or a kill left through the `Err` arm's unconditional
`hub.notify` — the home source (`/sethome`), worded "Cron job … failed" —
while the parent conversation sat waiting for a `task_completed` that would
never arrive. The wrong chat, and a wedged one.

The fix is a shape, not a branch: one `JobOutcome` classification, then one
`match job.kind` delivery site for every ending. An async task now ends in
its parent conversation whatever happened to it. The sink has a single
channel deliberately — to the model reading it, "it broke" is a result like
any other and must not be overlookable — so a failure is delivered as prose,
carrying whatever partial output the run produced, which is usually the only
clue about why. A cron job keeps the home notification: it belongs to nobody's
conversation. Cancellation becomes a third outcome rather than a flavour of
failure (`job_runs.status` has always had `'cancelled'` in its CHECK and
nothing ever wrote it), classified off the new typed `TurnCancelled` error so
nothing keys on a message string.

The strip above the composer is the visible half. `ServerEvent::TaskUpdate`
announces state to the source of the parent conversation only; the list is
`renderTaskStrip` (shared by the desktop copilot and the mobile chat), fed by
state on `ChatSession`. Each row links to `#session/{id}` — the page that
already shows, live, what a background agent is doing, and without which
"a task is running" is a fact you can do nothing with. Stopping is the
existing kill endpoint. A finished row clears itself after 20 s (its result
is in the conversation by then); a failed one stays until dismissed, and the
dismissal is remembered across reloads.

`GET /api/{source}/tasks` is what makes the strip survive a browser refresh:
the event is a broadcast with no replay, so without a load-time read a reload
would empty a chat that still has work running under it. It answers with the
running tasks plus failures from the last 30 minutes — the two states a person
can still act on. Successes are absent on purpose. Its window compares through
`datetime()` on both sides: `completed_at` is RFC 3339 and the cutoff is
SQLite-shaped, and `'T' > ' '` would let every same-day row through a window
meant to exclude it.

Not addressed, and worth doing next: a cron job's result should go where its
creator says, not always to the home chat.
2026-08-04 19:13:30 +01:00
dguiducci e356741435 fix: stop the file-viewer reload loop on watched files
Nightly Build / build (push) Successful in 7m35s
The watch callback forwarded every FS event, including the pure reads the
viewer's own GET /api/file produces (IN_ACCESS / IN_CLOSE_NOWRITE on Linux):
each silent reload re-triggered the watcher, looping at ~1 Hz. For PDFs every
iteration minted a new blob URL and re-assigned iframe.src, which re-runs
Chrome's whole PDF viewer (the flicker) and pushes a joint session-history
entry (the back button buried under hundreds of blob: entries).

- file_watch: forward an event only when the content version (mtime_ns, len)
  actually moved; drop Access events outright, stat-compare the rest.
- viewer: render pdf/latex/svg previews in a keyed() iframe — a fresh
  element's first navigation replaces its history slot instead of pushing.
2026-08-04 16:19:37 +01:00
dguiducci f900d803f2 fix: one tool-set recipe per session, so a tool cannot vanish between rounds
Nightly Build / build (push) Successful in 7m36s
Two "unknown tool (not in this turn's tool set)" failures, one disease: the
turn's tool set was rebuilt from a different recipe depending on which entry
point happened to drive it.

A sub-agent got `ask_user_clarification`, `execute_subtask` and `activate_tools`
and nothing else — while `agents/common/tools.md` and every reporting agent's
prompt tell it to register its output with `update_scratchpad`. The child could
see the scratchpad injected into its context but had no way to write to it. It
now gets the scratchpad and todos tools, on the parent's `scratchpad_sid`: one
blackboard per session, as the surrounding code already declared.

`show_file_to_user` was injected per message by the WS handler, while
`resume_session` and `resolve_pending_call` rebuilt the list with `execute_task`
alone. So approving a card, or reconnecting mid-turn, continued the *same*
conversation with the tool silently gone. There is now a single recipe,
`ChatHub::session_interface_tools`, used by all three paths and fed by a builder
the shell installs once through `Skald::set_interface_tools_builder`: the core
keeps owning the tool, the shell keeps owning the policy of who gets it —
Telegram still does not, since it cannot act on OpenFile.
2026-08-04 15:03:46 +01:00
dguiducci 6cb4ea0ce8 feat: let the fs-tools reach the whole container, and stop rebuilding the system prefix every round
Nightly Build / build (push) Successful in 7m33s
Two changes to what a turn costs and what it can see.

## The system prefix is frozen per conversation

`AgentSystemContext::system_context` is called once per round and reassembled
`base` from disk and SQLite each time, so an agent writing `user-memory/index.md`
in round 3 turned round 4 — seconds later, with the provider cache certainly
warm — into a full miss. `base` is the head of every provider's cache key, so it
is the most expensive string in the request to touch.

`PrefixCache` builds it once per (conversation, agent) and holds it on
`UserLoopRuntime`. The refresh rule is the only free one: rebuild once the
conversation has been idle longer than a provider's cache could survive
(20 min). The clock is idle time of the conversation, not time since a file
changed, and reading restarts it — every get is a request about to go out.

Writes are deliberately not reacted to. The agent's own edits are already in the
context, two messages downstream. A write from elsewhere is invisible until the
TTL: that is precisely where an immediate rebuild costs the most, and the
cheaper freshness path already exists — a `read_file` result appends, and
appending invalidates nothing. The injection header now says so.

Also removes 4 DB queries and 2 file reads per round.

## The security boundary is the container, not the mounted subtree

`read_file /tmp/cv.txt` answered "path escapes your workspace" and the agent
re-read the file with `cat`. It was right to refuse — /tmp exists only inside
the container — but the refusal protected nothing: `execute_cmd` already runs
there with passwordless sudo. The mount is the fast path, not the perimeter.

`resolve_target` now routes an absolute path through `container_to_agent`
first. Landing on a mount takes the host path, which also fixes a real bug:
`/root/x` IS `~/x`, yet every tool rejected it, because `PathBuf::join` with an
absolute tail discards the base and the result then failed the prefix check
(`/root/shared/{X}/…` too). Landing nowhere means container-only, served by the
new `container::exec_fs` over `docker exec`, with paths passed positionally so
a path containing `$(…)` stays data. Membership still holds: `/root/shared/{X}`
for a non-member fails exactly as `shared/{X}` does.

Single-file tools get this without a second implementation: `fs::Shuttle` pulls
the file out, runs the unchanged tool on the copy, and pushes it back if the
bytes changed. `list_files` lists in place, `read_file` reads container paths as
text (a shuttled copy cannot back a MediaRef), and `grep_files` refuses them
with a pointer to `rg` rather than approximating its own semantics. The viewer
follows the same routing, so the user can open what the agent read.

Host containment is untouched and still guards every mounted path — it is the
defence against a symlink planted in the container pointing at the host's /etc,
and the container branch never touches the host filesystem at all.

Verified end-to-end against a live skald-runtime:v3 container: write/read/edit
on /tmp round-trip, /etc/os-release reads, binary and shell-metacharacter paths
survive, and /root/notes.md lands in the host home.
2026-08-04 12:42:04 +01:00
dguiducci 85536755ee feat: a "Run now" button for the memory lints — one pass, for whoever asked
Nightly Build / build (push) Successful in 7m33s
The two memory lints run weekly, which is right for maintenance and wrong for
the moment somebody has just reorganised their notes and wants to know what the
lint makes of them. Each agent's tab now carries a button that starts one pass
immediately, for the caller.

It runs as the caller — their pool, their sessions, their hub — so the report
lands with the person who asked. The shared lint is the interesting case: its
scheduled pass runs as the admin because the shared store belongs to nobody, but
a member pressing the button reads the same store and gets the report themselves,
which is coherent with shared memory being readable by every member anyway.

Two settings are treated differently on purpose. Due-ness is skipped, exactly as
manual /compact skips the compactor's token threshold: the interval answers
*when*, and a human asking is a good enough answer to that. The Enabled switch
is honoured: it answers *whether*, and that one is the admin's.

The conversation review gets no button (AgentScope::PerSubject): it is about
somebody else and picks its own subjects, so "run it for me" has no meaning.
The frontend reads that from the agent's scope, not from a list of ids.

A second starter breaks an invariant the scheduler used to hold for free.
system_agent_runs::start sweeps any leftover `running` row of the same agent to
`failed` before inserting, which was safe only because one sequential loop was
the only thing that ever started a pass; a manual run overlapping a scheduled
one would have marked a healthy run as interrupted and duplicated its work. So
the agent list moves out of the scheduler and onto Skald as SystemAgents, which
holds the registry plus an in-flight guard both paths claim through — keyed on
what the pass is *about*, so an instance-wide agent is one slot no matter who
runs it, and a per-subject review is keyed on the subject rather than on the
supervisor lending the runtime.

has_work is answered synchronously, before anything is spawned: it leaves no run
row, so without that the button would say "started" over a log that never gains
a row. Everything after it is spawned — a pass is an LLM turn, and no HTTP
request should be held open for one. The run row exists before the browser is
answered, so the log itself is the progress surface; the page polls it quietly
until the pass leaves `running`.
2026-08-02 21:40:21 +01:00
dguiducci 11f4ba8ed2 fix: replace the six Italian user-facing strings with the English wording already used elsewhere
Nightly Build / build (push) Successful in 7m33s
None of them was an isolated slip — each already had an English twin somewhere
else in the system, so this is alignment rather than translation.

The four in `ws.rs` are the slash-command replies (/sethome, /cost x2,
/compact), and the Telegram plugin — the same command set, reached through a
different surface — has said them in English all along. The two surfaces could
answer the same command in two languages. Adopted Telegram's wording verbatim,
and made the /compact "nothing to summarise" line match its twin's spelling
while there.

The two in `skald-relay-server` are the APNs alert body. That one is worth not
re-deriving: it is the *fallback* shown only when the notification service
extension cannot run, and the iOS app localises the same message under the
English key "Action required" (Localizable.xcstrings, en + it). English was
already the canonical form on the other side of the wire — the hardcoded Italian
only ever surfaced in the one case where no locale is negotiated with anyone.
2026-08-02 21:24:38 +01:00
dguiducci baf68878e4 fix: stop shrinking conversations behind the user's back — both automatic context guards ship off
Nightly Build / build (push) Successful in 7m35s
The shipped default combined a sliding history window with no compaction, which
is the worse of the two available trades in both directions it is measured on.

`max_history_messages: 30` is a sliding tail window (`projection::window` —
`drain(..len - max)`). Past 30 messages it drops from the head on *every* turn,
so the prompt prefix changes on every single request and every provider that
caches one (Anthropic breakpoints, OpenAI automatic prefix caching) misses every
time. It also drops those messages with no summary standing in for them: silent
amnesia, not just a cold cache. Compaction rewrites the prefix once per
compaction and leaves a summary behind — yet it was the half that was commented
out, while the window's own doc-comment already said the two were exclusive.

Both are now `Option` and both ship unset, so nothing shrinks a conversation
unless a human types `/compact`.

Which surfaced the real bug: `/compact` did not work either. The compactor was
`Option<Arc<ContextCompactor>>` keyed on the config section existing, so
commenting out `compaction:` disabled the manual command too — `force_compact`
returned `Ok(false)` and the chat answered "compaction disabled". Manual
compaction is a command a user types; it cannot depend on an admin having filled
in a token threshold. The compactor is now built unconditionally and
`threshold_tokens: Option<u32>` arms only the automatic pass; `try_compact`
early-returns without it, `force_compact` deliberately never consults it.

The projection accordingly yields to the *automatic* pass rather than to the
compactor's existence (`LoopConfig.auto_compaction_enabled`), so a configured
message cap is not silently voided by `/compact` merely being available.
`CompactionConfig::Default` is hand-written for the same reason `RoleAttrs`'s is:
a derived one gives `keep_recent: 0`, which would compact away every recent
message on any box omitting the section — now the default.

Also fixes two documentation bugs in the same file: `event_triage` was documented
nested under `llm:`, where it parses fine and is then silently ignored (it is a
top-level field), and `datetime` was documented twice with conflicting examples.

A new test asserts the shipped default actually deserializes and that both guards
are off — a field the default omits must be genuinely optional, or a brand-new
install fails to boot.

Automatic compaction returns later, triggered off the resolved model's own
context window instead of a hand-tuned token count that cannot know which model
is answering.
2026-08-02 21:21:14 +01:00
dguiducci e6818408cb feat: grant a new plugin or connector to everyone by default — the admin's job is now removal, not distribution
Nightly Build / build (push) Successful in 7m23s
The grant junctions (plugin_access, mcp_global_access, mcp_catalog_access)
stay deny-by-default internally, but the rows are written for you at two
moments and never again:

  — an object is CREATED: PluginManager::update_config (first toggle —
    the plugins row's birth), mcp::catalog_upsert, marketplace install,
    mcp::global_enable
  — a user is CREATED: UserManager::register_user

Who is included is the role attrs.auto_grant flag (default true, so every
role predating the attribute behaves like an adult member). The seeded
Children preset sets it to false, which is the whole reason the attribute
exists. Admins are skipped because they hold everything implicitly. The
role editor now exposes the switch as a checkbox.

New crate module: db::access_defaults (seed_new_object, seed_new_user,
set_grant_by_default). Additive columns: grant_by_default on plugins,
mcp_catalog, mcp_global_servers (INTEGER NOT NULL DEFAULT 1).

On the frontend the Roles page gets a "New extensions" column and
checklist; the user's plugin/connector rosters are unchanged. i18n:
en, fr, it.

Docs: new docs/access.md for the assistant, plus index.md cross-link.
CLAUDE.md updated with a full default-access section.
2026-07-29 15:53:51 +01:00
dguiducci da8a835d70 move per-user plugin grants to the user's page
Nightly Build / build (push) Successful in 7m16s
Granting was a checklist of every user on each plugin's page, so "what may
this person use?" meant opening every plugin in turn — and the answer lived
on N pages while the connector half of it already lived on one. Both grant
sections now sit together on #users/{id}: same row list, same disabled chip,
same replace-the-whole-set save. The plugin's own page keeps a read-only
roster of who holds it, linking back to each person.

- db: plugin_access::set_for_user, the per-user twin of set_for_user on
  mcp_catalog_access; set_access stays as the inverse read model
- PluginManager: list_grants_for_user / set_grants_for_user, which omit and
  reject manages_own_access plugins (a box that controls nothing is worse
  than no box)
- GET/PUT /api/users/{id}/plugins, mounted next to /users/{id}/connectors;
  PUT /api/plugins/{id}/access is gone, GET remains as the roster

No push after the write, unlike a connector grant: that one gates a runtime
snapshotted at login, while a plugin grant is re-read from plugin_access on
every request that depends on it (sidebar pages, /plugins/mine, and each
inbound channel message), so a revoke lands with no bus event.

Docs updated with where access is granted, and why mobile-connector is
absent from that list.
2026-07-29 11:36:47 +01:00
dguiducci 046f060fcd rename the TIC system agent to event triage
Nightly Build / build (push) Successful in 7m16s
TIC said nothing about what the agent does, and named the wrong thing: the
tick belongs to the scheduler, which is generic and lives outside it. The
agent's only decision is whether an incoming event deserves an interruption
— it sorts, it never acts — so it is now event-triage, matching the
functional naming of the two memory lints.

- agents/tic/ -> agents/event-triage/, module tic/ -> event_triage/,
  TicManager -> EventTriageManager, TicConfig -> EventTriageConfig
- agent id and chat source: "tic" -> "event-triage"
- config keys: tic.* -> event_triage.*, and the config.yml section tic: ->
  event_triage: (greenfield: previously set values fall back to defaults)
- i18n en/it/fr: Event triage / Triage eventi / Tri des evenements; dropped
  the stale "TIC sessions" mention from the debug-pages description
- docs/system-agents.md, docs/index.md, docs/settings.md, CLAUDE.md, SKALD.md
2026-07-28 21:59:37 +01:00
dguiducci 434e27d7c2 system agents: generalise the scheduler and add the two memory lints
Nightly Build / build (push) Successful in 7m14s
Memory is kept as a maintained wiki, and a wiki nobody prunes rots. This adds
the scheduled maintenance pass, and generalises the machinery TIC had grown so
that a background agent is a trait impl rather than a loop of its own.

Two lint agents, not one. The private pass runs per user over `user-memory/`
and reports to them; the shared pass runs once over `shared-memory/`, where the
interesting defect is different — a note failing the table rule, i.e. private
business written where every member can read it. It names the note and the
category without repeating the content, since restating it spreads the very
thing being flagged. Both share `agents/common/memory-lint.md`.

Both are read-only, and that is enforced twice: the prompt says report-never-
repair, and `shared-memory/*` writes are already `@fs_write require`, so an
agent that tried to fix something would raise an approval card from an
unattended pass, which is auto-denied. Read-only is the only design that works
here, not merely the safe one.

One scheduler for cadences three orders of magnitude apart. TIC runs every few
minutes, a lint weekly — the case that tempts a second loop. It stays one
because the wake-up decides nothing: `base_tick` picks only how often to look,
and whether an agent runs for a user is `is_due` against persisted state.

Due-ness moves out of the run log into a new owner table, `system_agent_state`.
The two answer different questions: the run log skips idle ticks so it stays a
history rather than a heartbeat, while scheduling needs every attempt. Reading
due-ness off the log would re-run an idle agent on every tick and never bring a
weekly one due once its last productive run aged out. Persisting it is also
what makes a long interval survive a restart — an in-memory deadline is fine at
TIC's scale, but a weekly agent on a box rebooted every few days would have it
re-armed before it ever fired.

The shared store belongs to nobody, so `AgentScope::Instance` runs that pass as
the first unlocked admin. An ownerless run would write its trace into system.db,
which the runs endpoint shows to nobody by design, and its notify() would have
no recipient; attributing it to a user keeps the whole per-user surface working
unchanged.

Settings move to where the run log is. `ConfigSet` gains `owner`, so placement
is data on the set rather than a page that knows set names; the System agents
page grows one tab per agent holding its description, its settings (admin only)
and its runs — "why did this do nothing last night?" is half a schedule
question and half a log question. The form is shared with the Config page, and
writes still go through PUT /api/config/{key}.

Fixes an authorization gap found on the way: neither /api/config handler took
the caller into account, so any authenticated session could read and write
instance-wide config. The sidebar hiding the page is presentation, not access
control. Both are now admin-gated.
2026-07-28 21:24:16 +01:00
dguiducci 4b1affa600 plugins: merge the user Plugins page into per-plugin sidebar pages
Nightly Build / build (push) Successful in 7m1s
The generic per-user #plugins page is gone: a plugin with per-user
settings hosts them in its own web_pages() sidebar page instead
(Telegram's pairing page is new; Honcho's opt-in page already existed).
The admin catalog moves from #plugin-catalog to #plugins (old hash
redirected), and user_config_schema is removed from the Plugin trait,
the API DTOs and both plugins — the my-config endpoint, the
plugin_user_configs store and the update_user_config hook stay, now
driven by each plugin's own page fragment.
2026-07-28 20:48:03 +01:00
dguiducci 165af19774 tic: run per-user under a system-agent scheduler, with a run log
Nightly Build / build (push) Successful in 6m58s
Reframe TIC from an ownerless global loop into a per-user system agent.
The events it reads live in each user's own encrypted mcp_events, the
connectors that produced them run in that user's container, and the
notifications go to that user's hub — so the previous design (built
against the ownerless Conversation bundle, writing into system.db and
notifying a hub with no subscribers) was inert by construction.

Core changes
- TicManager owns no timer and no user list. It now exposes
  run_for(user_id, pool, sessions, hub): one tick for one user, over
  deps unpacked from that user's UserContext. Removed from the
  Conversation bundle; Skald::tic_manager() is gone.
- New spawn_system_agents in wiring.rs: one instance-wide loop, spawned
  post-construction with a Weak<Skald> (like spawn_user_lifecycle).
  Each pass walks the directory and runs TIC for one user at a time —
  sequential, because a pass is N container round-trips and N LLM calls
  nobody is waiting on. A ConfigKeyUpdated on the interval key cuts the
  current wait short; enabled is re-read per pass.
- A user whose database is still locked is skipped (normal, not an
  error): the pool is the unlock token, so a user who hasn't logged in
  since restart has no readable events and nowhere to record a skip.
- The configured tic.security_group is re-checked per user through
  run_context::reconcile_group_for_user — a restricted member never
  gets a tool set their role wouldn't grant; unconfigured starts from
  role_default_run_context, never None (None = catch-all = wider).
- New system_agent_runs owner table (no user_id column — the file is
  the owner): start/finish split so a crash leaves a visible 'running'
  row, swept to 'failed' by the next start; safe because the scheduler
  is sequential and single-instance. An idle tick writes nothing.
- counting_notify wraps the notify tool so the run log can report
  notifications emitted without the tool knowing it's counted.
- The session's event channel is drained by a spawned task instead of
  a dropped receiver — the translator awaits its sends and would wedge
  at capacity.

EventLog::{Persist,Discard} on McpManager::new
- mcp_events is an owner table and its only reader (TIC) is per-user,
  so an event is something that happened to someone. The per-user
  runtime gets Persist; the ownerless global runtime gets Discard (its
  pool is system.db, rows would be unattributable and unread).

API + UI
- GET /api/system-agents/runs: the caller's own run history, scoped
  through require_context with no admin override (same promise as the
  rest of the private pool).
- web/components/system-agents.js replaces tic-sessions.js. The old
  #tic debug page inferred runs from leftover ephemeral sessions; the
  new #system-agents page (sidebar group 'extensions', visible to
  everyone — the data is the caller's own) reads the real run log.
- i18n: tic.* keys replaced with system_agents.* in en/it/fr.

Docs
- New docs/system-agents.md (user-facing: what TIC does, why it runs
  per person, why a run can be missing). Updated docs/settings.md and
  docs/index.md.
- agents/tic/AGENT.md reframed per-user: events are that person's,
  memory is user-memory/ (private) — never shared-memory/.
- CLAUDE.md records the system-agents design and the EventLog seam.
2026-07-27 11:39:13 +01:00
dguiducci 305bdbdd2b connectors: announce global-server and reinstall refreshes on the bus
Nightly Build / build (push) Successful in 6m56s
Five call-sites reached into the live-runtime refresh helpers from HTTP
handlers, the same shape as the container remounts. Only three of them
belonged on the bus, and finding out which was the point.

global_enable and global_delete now emit McpGlobalServersChanged, and the
marketplace reinstall emits ConnectorReinstalled. All three are pure
reconciliation: the first only makes a connector appear; the second is
already enforced by stop_server, with the snapshot refresh just tidying
each user's filter; the third pushes metadata and code into what is
already running. The reinstall gains something from being off the
response path, since it re-copies files and restarts servers inside every
live user's container.

global_set_access and user_connectors_set keep calling
refresh_global_mcp_access directly. Their writes *replace* a grant set, so
anyone dropped from the list is being revoked and that refresh is what
enforces it — on a best-effort broadcast a revoked user would keep the
connector until their next login. Both carry a DELIBERATELY SYNCHRONOUS
comment, since they are otherwise indistinguishable from the announced
call-sites and are exactly what a later cleanup would sweep up.

No behaviour change for the two synchronous paths; the three announced
ones now return without waiting for the refresh.
2026-07-26 22:22:29 +01:00
dguiducci 0ba140186f auth: make deactivation and group revocation actually revoke
Nightly Build / build (push) Successful in 6m58s
Two variants of the same defect: an admin took away access and the running
system kept granting it.

Deactivating or deleting a user only stopped the *next* login. `login`
checks the active flag, but `require_auth` maps token -> id without
re-reading the row, so an already-open session kept working over a pool
whose key was still in RAM. There was no way to stop one user either: the
per-user cron, hub and MCP loops all observed the *instance* shutdown
token. They now take a per-user child token stored on UserContext, and
Skald::revoke_user_runtime tears a single user down in a load-bearing
order — revoke every session, evict and cancel the context, then lock the
database, so nothing is left querying a pool we are about to close.

Revoking a security group had a durable version of the same problem. The
group is validated when selected and then persisted on
chat_sessions.run_context, which was replayed verbatim on every later
load — so a group removed from a role stayed in force on sessions that
already had it, across restarts. get_or_create_handler now runs the stored
value through run_context::reconcile_group_for_user, making it advisory:
every load re-checks it, whether or not anyone announced the change.

The degrade target is the role's default group, never None: a context with
no group resolves to the catch-all `default`, whose rules are the fallback
tier under every other group, so clearing widens rather than narrows. The
reconcile touches only security_group, so a project session's server-built
project_root and system_prompt survive a permissions edit, and it leaves
the stored group alone when the role cannot be resolved — guessing on a
transient error could only widen. role_default_run_context moves into the
core seam so the group a session starts on and the group it falls back to
cannot drift apart.

Both fixes run synchronously in their handlers. Only the container half of
deactivation rides the bus, as the new UserActiveChanged event: a lossy
64-slot broadcast whose contract is "settles at the next login" is the
wrong transport for taking access away.

Tests: revoke_user drops all of one user's sessions and nobody else's, and
is a no-op when nothing is live; the reconcile degrades a revoked group to
the role default, keeps an allowed one, preserves project fields in both
directions, never touches an admin, and stays put when the role is
unresolvable.

Not exercised at runtime: no Docker/live-server run, so the end-to-end
paths (deactivating a logged-in user, editing a role with sessions open)
are covered by unit tests only.
2026-07-26 22:17:30 +01:00
dguiducci c50a0d84da containers: drive user provisioning and remounts from the system bus
Nightly Build / build (push) Successful in 6m51s
The endpoints that changed a user or a membership row also reached into
ContainerManager themselves: users_mgmt called ensure()/remove(), and both
shared_folders and projects called refresh_user_mounts through a local
remount() helper. Every future endpoint that grants membership would have
had to remember to do the same.

Announce instead. SystemEventBus gains UserCreated / UserDeleted /
UserMountsChanged, emitted after the DB write, and one subscriber —
wiring::spawn_user_lifecycle — does the Docker work: sequentially (which
serialises concurrent operations on the same container), best-effort by
contract (the row is already committed, so a hiccup settles at the user's
next login or at boot reconciliation), and holding only a Weak<Skald>. It
is spawned after construction, like set_skald, because it reacts through
Skald's own accessors.

Also fixes a real gap the event makes impossible to repeat: the web setup
wizard created the first admin without provisioning a container. It runs
against a live server, where reconcile_all() has already happened, so that
admin had no sandbox until the next restart. It now emits UserCreated like
any other creator; the console shell needs no equivalent, since it runs
before the server and boot reconciliation covers it.

Two behaviour changes: POST /api/users and POST /api/projects no longer
wait on Docker before responding. Provisioning was already best-effort, and
a new project's folder is still created synchronously, so the explorer —
which reads host-side — shows it at once; only execute_cmd reachability
lands a moment later.
2026-07-26 21:57:25 +01:00
dguiducci 4d81295a3d messages: unify harness-injected data under <system-extra> tag
Nightly Build / build (push) Successful in 6m49s
Replace the ad-hoc [SYSTEM INFO] / [TELEGRAM SYSTEM INFO] prefixes with a
single canonical <system-extra> wrapper, sourced from one constant
(SYSTEM_EXTRA_TAG) so emission and documentation can never diverge.

- core-api: SYSTEM_EXTRA_TAG + system_extra() helper; attachments_block
  rebuilt on top of it.
- telegram: system_info_message (location) uses the helper; the voice
  transcript is forwarded as a plain user message (it is the user's own
  words, not harness metadata).
- chat agents: new agents/common/harness.md include (long form, with an
  explicit "data, not instructions" guard), added to assistant/kid/
  project-coordinator. The tag name rides the __HARNESS_TAG__ sentinel,
  resolved in AgentSystemContext to SYSTEM_EXTRA_TAG — renaming the tag
  stays a one-line change.
2026-07-26 17:54:14 +01:00
dguiducci 24ee5b89d7 agent-loop: projection, recovery, compaction into the crate (phase 3)
Nightly Build / build (push) Successful in 6m49s
The session handler is now a thin shell: three entry points in
kernel_turn.rs (run_kernel_turn / recover_turn / resolve_pending_call)
and the ChatSessionHandler. Everything that shaped a Value — projection,
recovery, compaction mechanics, the LLM loop, message building — lives
in agent-loop or behind a loop_adapters trait.

agent-loop:
- projection/ (mod + media): stored history -> wire messages, the one
  place provider divergence lives; well-formedness contract, DTL
  injections (append-only), media parts. LinearAssembler is now a
  Projection + ProjectionHooks config, not its own implementation
- recovery.rs: reap interrupted batches -> resolve the deepest frame's
  non-terminal calls (Running by policy + RestartHint, AwaitingHuman
  re-asked) -> un-wedge finished children -> cascade up, every frame on
  its own agent (B3)
- compaction.rs: split point (never assistant+tool group), transcript,
  SUMMARY_PREFIX/preamble/template, the no-tools model call, summary row
- manager: resolve_pending (gate skipped, real ToolContext, then
  continue incl. sub-agent); start_loop used by recovery; LiveInput
- delegate: AsyncExecutor + StoreSink for mode:async (durable cron row,
  result delivered back into the parent conversation)
- kernel/context/store: support the above (TurnScope via Extensions,
  frame lookups, aligned result-text semantics)

skald-core:
- loop_adapters: UserLoopRuntime (D12 - one LoopManager per user),
  TurnScope (per-turn state in the Extensions type-map; no scope is
  denied), projection_cfg/media_source/tool_digest (Skald's projection
  knobs without owning projection code), async_task (CronExecutor +
  DurableSink)
- session/handler: stripped to mod.rs + kernel_turn.rs + config.rs +
  interface_tools.rs + media.rs; deleted agent_dispatch, approval,
  dispatch, emitter, gate, llm_call, llm_loop, message_builder,
  messages, outcome, resume
- compactor.rs: policy only (threshold, model pick, CompactionEvent);
  mechanics are the crate's

CLAUDE.md updated (recovery, compaction, sub-agents, approval gate,
projection sections now describe the crate-owned flow).
2026-07-26 17:09:01 +01:00
dguiducci 5081ec2afe llm: drop model/agent scope matching; add instance-wide compaction model picker
Nightly Build / build (push) Successful in 6m50s
Remove the scope system end-to-end (llm_models.scope column, agent meta
scope field, scope-based tier in model selection, UI checkboxes/pills):
it was only a soft ranking hint, had drifted (6 UI scopes vs 3 used by
agents, 'general' not even selectable) and duplicated what strength
already decides. Strength stays the single AUTO-selection axis.

Compaction: the summary model is now pickable from the Settings page
via a new PropertyType::LlmModel config property (registry key
compaction_model), instance-wide and live (no restart). Fallback chain:
explicit pick -> compaction.strength from config.yml -> priority order;
a deleted configured model degrades to AUTO. ContextCompactor reads the
key at compact time through GlobalConfigManager.
2026-07-25 10:48:09 +01:00
dguiducci 3c52587dee file viewer: edit Markdown with optimistic-lock conflict detection
Nightly Build / build (push) Successful in 6m47s
- GET /api/file returns ETag (mtime+size) + X-Writable on disk files;
  PUT /api/file accepts optional if_match -> 409 Conflict on stale version
  (last-write-wins preserved when omitted), echoes the new ETag
- FileViewerBase: View | Edit tabs for .md when the caller can write;
  source textarea with Save/Cancel, live preview while editing
- Watcher no longer clobbers the buffer mid-edit: while editing with
  unsaved changes it probes the server ETag and only raises a conflict
  when the remote actually moved on (own-save echo is ignored)
- Conflict banner: Reload remote | Copy mine, then reload | Overwrite
- i18n (en/it/fr) + CSS; docs/projects.md updated
2026-07-23 22:03:43 +01:00
dguiducci 42c0eaf2ec Live-refresh connectors after marketplace reinstall
Nightly Build / build (push) Successful in 6m45s
After a reinstall the catalog entry carries new llm_short_description,
icon and code. Previously the running servers (global + per-user) kept
their old metadata and code until the next login.

- Add refresh_connector_after_reinstall on Skald: re-snapshots the
  description from the catalog, reconciles local files on per-user
  connectors, and restarts both the global and per-user servers
- Add set_description db accessor for mcp_global_servers
- user_row_spec_resolved now injects the live catalog description
  (over the bare name) so user-runtime connectors show the right blurb
- marketplace install() fetches a fresh feed instead of the browse
  cache, so a reinstall reflects the changed manifest immediately
2026-07-22 23:21:03 +01:00
dguiducci 6f0461f7f5 uploads: centralise via ChatHubApi::save_upload, refactor handlers
Nightly Build / build (push) Successful in 6m41s
Extract shared upload seam in skald-core, move Telegram and web
handlers to use it. Simplify media attachment routing. Clean up
unused deps and dead code.
2026-07-22 19:19:29 +01:00
dguiducci e70c4a90f3 file viewer, docs, ws: add image/media preview path, projects doc, ws wiring
Nightly Build / build (push) Successful in 6m44s
Show file gains image and video display for capable agents. Docs add
projects.md and update index. Wire ws file-watch in project-board.
Minor fs tool and CLAUDE.md updates.
2026-07-22 18:52:03 +01:00
dguiducci f34f800e5c projects: file explorer, ws improvements, i18n, and fs routing
Nightly Build / build (push) Successful in 6m47s
Add project-files component with tree navigation. Extend UserFs with
shared-folder resolution. Wire API routes for file browsing. Improve
WS session lifecycle and project-board layout. Add i18n keys for
projects and inbox across all locales.
2026-07-22 18:35:13 +01:00
dguiducci 3343260bb0 token streaming & reasoning display: live SSE tokens frontend to back
Nightly Build / build (push) Successful in 6m59s
- Add StreamDelta(SseDecoder) framing shared by OpenAI/Anthropic
- OpenAiClient: stream=true + reasoning_content deltas, index-based
  tool_calls accumulation, usage from final chunk
- AnthropicClient: message_start/content_block_*/message_delta events,
  thinking_delta->reasoning, input_json_delta->tool input
- TokenDelta ServerEvent variant wired through ChatHub + WS broadcast
- Frontend throttled flush (~15 Hz), pending bubble mutate-in-place,
  reasoning as collapsed-by-default <details>
- Drop streaming bubble on error/llm_failed/model_fallback
- i18n: chat.reasoning key added to en/fr/it
2026-07-22 12:59:13 +01:00
dguiducci 7e9127dc69 remove agent-callable restart tool (blast radius in multi-user model)
Nightly Build / build (push) Successful in 6m37s
2026-07-22 09:22:45 +01:00
dguiducciandClaude Opus 4.8 5d5c3ff2ff mcp: live-refresh global connector access without restart; add MCP list to kid agent
Nightly Build / build (push) Successful in 6m36s
A user's session sees global MCP connectors through UserMcpView, filtered by
accessible_global — a snapshot of mcp_global_access taken when the user's
UserContext is built at login. That context is cached until restart, so an admin
enabling/deleting a global connector or changing its access set was invisible in
MCP_LIST (and in the tool surface) until the whole process restarted.

Make accessible_global a swappable cell (SharedGlobalAccess, the MCP twin of
SharedFs for §6 fs remount): UserContext::refresh_global_access re-reads the
registry and stores it in place, and Skald::refresh_global_mcp_access broadcasts
that to every live context. Wire it into global_enable, global_delete,
global_set_access and user_connectors_set so a grant/enable is reflected in
running sessions immediately.

Also add the shared common/mcp.md include (the <!-- MCP_LIST --> sentinel) to the
kid agent, aligning it with the other agents.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 00:22:35 +01:00
dguiducci c11702c3d3 tool card UI redesign: semantic icons, inline diff persistence, tool detail page, MCP-friendly titles
Nightly Build / build (push) Successful in 6m38s
2026-07-21 23:39:41 +01:00
dguiducci 8ff64cbddc rename agents/main→assistant, role-based default entry agent
Nightly Build / build (push) Successful in 6m31s
2026-07-21 21:40:06 +01:00
dguiducci 17f5769e0d mcp: per-user connector access control with deny-by-default grants
Nightly Build / build (push) Successful in 6m33s
2026-07-21 20:48:56 +01:00
dguiducci c8e4cb4384 run container as host uid:gid, robust /stop, project paths as full agent paths
Nightly Build / build (push) Successful in 6m31s
2026-07-21 10:47:12 +01:00
dguiducci b627db761b scope file ops (ws, api, tools) to per-user context
Nightly Build / build (push) Successful in 6m28s
2026-07-20 23:04:24 +01:00
dguiducci 2ec394c17c Projects: shareable, registry-backed, container-mounted; drop ticket board
Nightly Build / build (push) Successful in 6m27s
Rework projects from single-user leftovers into shareable endeavours.

- DB: move `projects` from the owner bucket to the registry (system.db,
  not encrypted); add `owner_user_id` + `slug` (drop free `path`); new
  `project_members(project_id, user_id, can_write)` mirroring
  `shared_folder_members`. Drop `project_tickets` entirely. Only user↔agent
  conversations stay encrypted (per-user DB) — each member keeps a private
  project chat. Registry home dissolves the cross-DB-FK problem.
- Filesystem/container: on disk `{WD}/projects/{owner_userid}/{slug}`,
  agent/container path `projects/{owner_username}/{slug}`. Two-segment routing
  in UserFs (ProjectMount + host_base_and_tail arm) and a second loop in
  build_user_fs; read-only members get a :ro mount. Reuse the shared-folder
  remount machinery (refresh_user_shared_folders -> refresh_user_mounts).
- Remove the ticket system: ProjectTicketManager, UserContext.tickets, its
  wiring, and the project_tickets references in scheduled_jobs/cron.
- API: repoint handlers to the registry pool + membership scoping. Sharing is
  self-service — owner or any write-member may add/remove members and set
  read/write; only the owner deletes; the owner cannot be removed. New
  POST/DELETE /api/projects/{id}/members[/{user_id}]. Seed `@fs_any allow
  projects/*`; build_runtime_run_context sets working_directory to the agent
  path and drops the host-path allow_fs_writes.
- Frontend: create form without the free path field, owner/read-write badges;
  the detail page becomes header + description + sharing panel + Open chat + a
  file-explorer placeholder (the future primary surface). i18n en/it/fr.
2026-07-20 22:21:10 +01:00
dguiducci fefcf95362 mcp: install dep reconciler, connector login/status endpoints, agent prompt updates
Nightly Build / build (push) Successful in 6m26s
2026-07-20 17:39:07 +01:00
dguiducci 44dc67cda0 Setup: utente admin via web, ruoli e run-context con security-group, onboarding install/uninstall script
Nightly Build / build (push) Failing after 6m12s
2026-07-20 12:54:56 +01:00
dguiducci bee1b4cddb Fix: absolute path per tarball in package.sh (evita tar in temp dir)
Nightly Build / build (push) Failing after 1m13s
2026-07-20 00:02:25 +01:00
dguiducci fb3eeeeec6 Refactor: remove desktop/Tauri bundle, add i18n, CI/CD pipeline
Nightly Build / build (push) Failing after 6s
- Remove desktop (Tauri) bundle: docs/desktop.md, icons/, tauri.conf.json,
  src/desktop/mod.rs, gen/schemas/
- Remove build.rs (no longer needed)
- Add i18n system (crates/core-api, plugin-mobile-connector, web)
- Refactor config system (src/config.rs, boot_format.rs)
- Add mobile connector features (app, router, device pairing)
- Plugin system improvements (skald-core)
- Update dependencies (Cargo.lock, Cargo.toml)
- CI/CD: Gitea Actions workflows (nightly + release), package.sh,
  verify-version.sh, builds.skaldagent.net config
2026-07-19 22:35:06 +01:00
dguiducci ba911ae8cb feat(plugins): plugin pages, per-user config, capabilities gate, mobile/telegram refactors
- Plugin HTTP routes + web pages (plugin-page-host, plugin-catalog, plugin-detail)
- Plugin access grants + per-user config (DB tables + API + frontend forms)
- Capabilities-based guard (caps.rs) replacing role-id checks
- Mobile connector: message routing, payload types, router refactor
- Telegram bot: auth flow, event handling improvements
- Honcho plugin: substantial rework
- Sidebar: plugin pages integration, role-driven visibility
- i18n: new strings for plugins, connectors, capabilities
- Remove unused mascot asset
2026-07-19 20:47:09 +01:00
dguiducci f85876350e feat(mcp): WhatsApp connector, archivable catalog, MCP connector config endpoint 2026-07-19 10:55:44 +01:00
dguiducci ffea3f41f9 i18n: traduzioni it/fr per tutti gli agenti 2026-07-19 09:30:25 +01:00
dguiducci c389962e3c Profilo utente (birthdate/sex/notes), kid agent, i18n, shared folders, UX 2026-07-19 08:45:17 +01:00
dguiducci 126886e309 Major rebranding, i18n, dashboard, shared folders, role capabilities
- Rebrand: new app/agent icons, SKALD.md, warm "paper" CSS palette
  (terracotta accent, --radius tokens, WCAG contrast, reduced-motion),
  updated favicon, tray icon, skaldkonur asset
- i18n: backend crate (i18n.rs, locale column, ui_locale config),
  frontend library (web/lib/i18n.js, I18nMixin, t(key)),
  translation files (web/i18n/), every component wired
- Dashboard: <dashboard-page> replaces old home-page content;
  <app-copilot> becomes the landing page (full/dock layout modes)
- Shared folders: API endpoints (shared_folders.rs), frontend page,
  can_write membership, container mount topology, user_fs routing
- Role capabilities: new db table & authorization seam (data not enums),
  roles.attrs JSON for ui_mode / interface select
- Setup: skald-setup prompts for language + password, sets ui_locale
- General: components migrated to CSS variables, Lit conventions cleanup,
  connectors/catalog/marketplace/approval refactoring
2026-07-18 21:38:42 +01:00
dguiducci 2b35312abd feat(media): multimodal attachments — image/video inlining per model capabilities
Adds media.rs for per-turn attachment routing, message_builder
partitioning by resolved model capabilities, controller endpoints
for uploads, and server routing for /data/* behind session auth.

See CLAUDE.md §Multimodal attachments for full design.
2026-07-18 18:02:45 +01:00
dguiducci e6c4e202a4 feat(mcp): OAuth per-user connectors (§15) — providers, PKCE copy-paste flow, env credential delivery
- oauth_providers registry table (per-provider client creds) + db/oauth_providers.rs
- mcp/oauth.rs: authorization-code + PKCE S256, RAM-only TTL'd flow store, copy-paste consent
- mcp/install.rs + verify.rs: connector file install + manifest verification
- activate persists a pending row (needs_oauth); /mcp/oauth/start + /complete exchange code for refresh token
- credential delivery via env var on docker exec (google_authorized_user JSON), never on disk
- mcp_catalog/mcp_user_servers: additive OAuth columns (ensure_column), catalog_name/oauth_provider/deliver_json bare TEXT snapshots
- frontend: connector-detail.js (OAuth login panel), shared/connector-common.js, connectors.js admin Sign-in providers modal
- API: /mcp/providers (admin OAuth creds), /mcp/oauth/start|complete
- .gitignore: add /homes/ (instance data), /connectors/, /reset.sh; drop stale /secrets/
2026-07-17 21:47:51 +01:00
dguiducci bcd8f7b5c0 feat(mcp): connector marketplace + split the Connectors surface (§7/§14/§15)
Fills a gap the blueprint names: the admin had to hand-author every
`mcp_catalog` entry. A remote feed of vetted connectors now proposes them
and the admin installs — the feed is *consultative*, so §14's risk axis is
untouched and the trust anchor stays on the box.

Marketplace client (`src/frontend/api/marketplace.rs`):
- Fetches the feed server-side (it sends no CORS headers) and caches it;
  icons are proxied for the same reason.
- Verifies every declared SHA-256 before writing, fail-closed and
  all-or-nothing. Feed-supplied paths are refused if they escape
  `./scripts/<id>/`. Importing an `mcp_local` entry still demands the
  admin-only `mcp.register_local_script`.
- Translates the feed's vocabulary into Skald's: `user`→`per_user`,
  `mcp_local`→`local_script`. Scope is read, never inferred from transport
  (a remote connector can be per-user — that is what `mcp.register_remote`
  is for), and an unreadable `type` fails closed to the answer needing more
  authority. The feed's `llm_short_description` maps to `description`, the
  column `render_mcp_list` puts in front of the LLM for `activate_tools()`.
- Feed URL is config (`marketplace.url`), not a constant: an on-premise
  product must not hard-require reaching one vendor's host.

Two silent failures found while wiring it:
- `transport_of` maps anything unknown to Stdio, so the feed's
  `streamable-http` would have tried to spawn a command. Normalised on import.
- Some servers want their key as a query param, not a bearer header, and say
  so with a `{key}` placeholder. Substituted at connect time in
  `global_row_spec`/`user_row_spec` — never at rest, so the key stays in its
  own column and the stored URL stays a template.

Pages, split by the question each answers:
- Connectors — what runs (`UserMcpView` = global ∪ per-user) and what I can
  add. Same page for everyone; the admin just has more verbs. One Available
  list with the verb per row: `per_user`→Activate, `global`→Enable globally.
  Enabling a global is the admin's counterpart to activating a per-user one,
  so the catalog picker dropdown is gone — the entry comes from the row.
- Connector Catalog (admin) — what this box offers. One `Add connector`
  with two sources: marketplace first (vetted, hashed), manual second
  (unvetted by nature) — the order mirrors the trust model.
- Marketplace (admin) — reached from the catalog, not the sidebar: it is a
  destination of an action, not a place.

`available()` no longer returns `McpGlobalServerRow`: that row carries
`api_key` and this view now reaches every logged-in user. A slim `GlobalView`
crosses instead, and an admin sees every global (with `can_use` marking their
own) so one enabled for someone else stays manageable.

Also fixes `connectors-page` having no CSS rule at all — every sibling page
has one, so it never got `flex: 1` and left an empty column beside it.
2026-07-16 18:52:59 +01:00
dguiducci 6d299472e3 feat(mcp): Connectors — catalog + global vs per-user runtimes (§7/§14/§15)
Re-architects MCP from one owner table + agent-written registration into an
admin-curated catalog with two runtimes unioned per session, surfaced in the UI
as "Connectors" (mcp/schema stays neutral, §0.1).

Two runtimes behind one seam (§7):
- Global runtime: shared, stateless connectors (web-search, Tavily…) on the
  HOST, connected at boot from mcp_global_servers, access-filtered per user via
  mcp_global_access.
- Per-user runtime: a user's activated connectors run INSIDE their container,
  started at first login from mcp_user_servers and living until restart (§9);
  docker exec -i children die via kill_on_drop when the UserContext drops.
- McpProvider trait (mcp/provider.rs): the session round-loop never learns which
  runtime owns a server. McpManager implements it directly (inert ownerless
  bundle); UserMcpView implements global ∪ user with an accessible_global
  snapshot. Both share McpManager::connect_all; McpServerSpec +
  global_row_spec/user_row_spec turn a DB row into a connectable spec.
- mcp-client: McpServerConfig.launch_in runs a stdio command inside a container
  via docker exec -i (set at runtime, never parsed from config).

Authorization is a capability on the role, not `if role==admin` (§0.1/§14):
role_capabilities table + db/role_capabilities.rs — register_remote and
register_local_from_catalog are self-service (seeded on every new role), while
register_local_script and manage_catalog are admin-only. admin holds every
capability by construction. This removes the agent-facing register_mcp/delete_mcp
tools and the mcp kinds of list_items/toggle_item, closing the §14 RCE vector.

Schema:
- Registry: mcp_catalog (vetted templates — schema only, no live creds),
  mcp_global_servers + mcp_global_access, role_capabilities.
- Owner: mcp_user_servers (per-user activations; api_key encrypted at rest,
  catalog_name a bare TEXT snapshot, never an owner→registry FK).
- Drops the old owner table mcp_servers.

API + UI: src/frontend/api/mcp.rs (admin catalog/global/access + user
available/activate/activated, all capability-gated via require_cap);
web/components/connectors.js (<connectors-page>) renders the user view always
and the admin view for role_id === 'admin'.

Deferred: interactive per-user auth (OAuth callback / QR / SSH elicitation, §15)
— only none/api_key wired; no boot seed of catalog presets; per-(user, session)
MCP grant model still open.
2026-07-16 16:44:12 +01:00