505f2e95c163f4970898a5ababe18a0f6ea8967e
43
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
488c702517 |
feat(files): a Files section over the caller's whole space
Nightly Build / build (push) Successful in 5m36s
Until now file browsing existed only inside a project, and the two memory
stores were reachable only by the agent's tools. `#files` is the general
surface: home, both memory stores, the shared folders and projects the
caller belongs to, plus the read-only skills and docs trees.
The root is virtual, and that is the design. Anchoring at `~` is wrong:
the explorer reads host-side, while `shared/`, `projects/`, `skills/` and
`docs/` are bind mounts inside the container — a page rooted at the home
would show less than the user has with no way to reach the rest, and on
native Linux would show Docker's empty mountpoint stubs, a door that
appears to work and leads nowhere. So level 0 is a synthetic list from the
new `GET /api/files/roots`, serialized from the caller's `UserFs` plus the
two virtual memory roots. It sends `kind`, never a label: labels are copy
and get translated.
`GET /api/files/dir` now answers `{ path, can_write, entries }`, and a
memory path is classified before `resolve_view_path` (which refuses one)
and listed from `memory_docs`: one level derived from the flat key space
by the pure `memory_docs::immediate_children`, over a single query whose
unslashed prefix also spots an exact note as "not a directory". Memory is
read-only from the page — every writer routes through `resolve_view_path`,
and `shared-memory/*` is `@fs_write require` for the agent, so a button
that walks past that rule is a decision of its own.
The explorer moves out of projects into `shared/file-explorer.js`, taking
`root` + `rootLabel` and reading `can_write` from the listing rather than
from its host: writability changes per branch and comes from the same
`UserFs::can_write_to` the server rejects writes with, so the buttons
offered and the writes accepted cannot disagree. Deep-linking needed it
steerable without a two-way binding, hence `rel` in and
`explorer-navigate` out — the event fires only for a click, never for a
`rel` the host set, so echoing it back is a no-op.
The URL carries the agent path of the open folder in one parameter, the
same vocabulary the assistant uses, so a link is shareable and pasteable
into a conversation; which root it belongs to is derived, not stored.
docs/: a new files.md, plus two pages this made false — shared-folders.md
claimed in three places that a shared folder has no explorer, and
memory.md never said a user can now read their own notes.
|
||
|
|
e7c802f0d7 |
feat(event-triage): per-user check interval, overriding the instance one
Nightly Build / build (push) Successful in 5m4s
Event triage is the one system agent whose right cadence depends on who it
runs for: it fires on inbound events, so someone on a dozen mailing lists
has something waiting on nearly every tick while a quiet account has
something waiting almost never. A single instance-wide interval serves one
of them badly, and the observed failure is the first: the agent starts on
practically every pass.
An admin can now set a per-person interval on that user's page (Users ->
the person -> Event triage). Empty means "follow the instance setting",
which stays the state nobody has a row for.
- New registry table `system_agent_user_settings(agent_id, user_id,
interval_secs)`. A row is an override and its absence is inheritance --
no sentinel value, no row seeded at user creation, clearing the field
deletes the row. Registry rather than the user's own file because the
writer is the admin and a member's database is unreadable unless they
happen to be logged in; a setting that could only be changed during its
subject's session would not be a setting. Keyed by agent_id though only
one agent uses it, so a future agent's schedule is not a schema change.
- `SystemAgent` gains `interval_secs_for(user_id)`, which `is_due` now
measures against, and `shortest_interval_secs()`. Both default to the
existing `interval_secs`, so every other agent implements nothing. The
second is the non-obvious half: `base_tick` sleeps for the shortest
interval any enabled agent asks for, so without it an override below the
instance value would be rounded up to it -- an override that works when
it lengthens and silently does nothing when it shortens.
- `GET/PUT /api/users/{id}/event-triage`, admin-gated, minutes on the
wire, null to clear. Nothing rides the bus: the scheduler re-reads the
interval every tick and due-ness is counted from the user's own last
attempt, so a change lands on the next wake-up with no push.
Both helpers fail open onto the instance value -- an unreadable registry
must not turn into an agent that stops running for someone.
Docs: docs/system-agents.md gains the per-person section and no longer
reads as if the interval were one number for everybody.
|
||
|
|
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.
|
||
|
|
c0a779b79e |
fix: let an admin use the connectors they implicitly hold
Nightly Build / build (push) Successful in 7m50s
Activating a per-user connector as admin failed with "you are not
authorized to use this connector — ask an admin to enable it for you".
`db::access_defaults` deliberately writes no grant rows for admins, and
says why: "they already hold every plugin and connector implicitly, so a
row for them would be noise". That implicit hold was only ever
implemented for plugins (`plugin_access::effective_access`). The two MCP
grant tables had nothing but the raw junction read, so an admin ended up
with no row *and* no short-circuit — denied their own connectors, and
denied more the more the seeding was trusted to skip them.
The reported symptom was the mildest of four:
- `activate` refused, while `available` listed the entry (an admin
holds `mcp.manage_catalog`) — visible but unusable;
- the login-time startup filter dropped an admin's already-activated
catalog connectors, so they silently stopped running;
- `accessible_global` snapshotted an empty set, so an admin's sessions
were offered no shared MCP tools at all — no error, just absence;
- the connector report told the agent an admin's own global connector
was "not granted to you".
`users::is_admin` is now the single predicate behind every "admins hold
it implicitly" short-circuit, and `plugin_access` was moved onto it too:
three tables open-coding the same role lookup is what let one of them be
written without it. Each MCP table grows an `effective_access` beside its
`has_access`, and the distinction is the point — `has_access` stays the
roster question ("what did the admin tick"), which the access-editing
surfaces must keep asking, while the gates ask the authorization one.
Nothing widens for anyone else: deny-by-default is untouched for
non-admins, an unknown user is nobody, a disabled global stays excluded
for admins too, and the `not_granted` report branch survives for a
non-admin who was given the catalog-management capability.
|
||
|
|
de21d9a64b |
fix: give the notification home a place to live in the owner's database
/sethome answered "no such table: config" from every surface. ChatHub is
owner-bound, so its pool is a {userid}.db, and `config` is a registry
table that only exists in system.db — the write had no table to land in.
The visible half was the lesser one. The notification consumer resolves
the home source before it delivers anything, and on an error it dropped
the batch: every `notify` from a background agent and every cron-job
completion has been discarded, silently, for as long as the hub has been
per-user. That error path now degrades to the default home instead — a
batch that got that far is data nobody can recreate, and the destination
is the one thing there with a sane fallback.
Where the setting belongs was never in doubt: one member choosing
Telegram must not move anybody else's notifications, so it is owner
state and it goes in their own file. The new owner table is `user_config`
and it deliberately does not reuse the registry name. The two hold
different namespaces — instance settings the admin owns versus one
person's own preferences — and a table called `config` in both files
would have turned this exact mistake into a silent read of the other
scope, which is strictly worse than the loud failure that revealed it.
Additive, so no migration: open_user_pool re-applies the owner schema on
every unlock, and the table appears at each user's next login.
|
||
|
|
e5c0f53f75 |
fix: re-apply the owner schema when a user database is opened
Nightly Build / build (push) Successful in 7m49s
open_user_pool ran only the key probe, so ensure_column never reached
pre-existing {userid}.db files: users created before an additive column
(e.g. chat_sessions.is_open) was introduced hit 'no such column' at
their next login. create_owner_tables is idempotent, so running it at
unlock lands additive changes per user, at the only moment an encrypted
file is readable.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
4f10528368 |
feat: conversation review — a nightly report on a supervised person's conversations
Nightly Build / build (push) Successful in 7m40s
The first AgentScope::PerSubject system agent, and the reason that scope exists. Once a night, for each person with a supervision edge, it reads every message that person and the assistant exchanged since the previous review — across all their conversations — and writes one report for the people who supervise them. Schema (all registry except reports): - supervision(subject_user_id, supervisor_user_id): the generic §0.1 edge, answering both 'whom does a background agent look at' and 'who may read what it produced', with real FKs so deleting a user cascades both ways - system_agent_coverage(agent_id, subject_user_id, covered_through): the per-subject watermark that makes 'everything since last time' a window — neither system_agent_runs (history for humans) nor system_agent_state (advances before the work), and advanced only on a completed pass so a crash re-covers instead of skipping - reports (owner schema, the second two-homes table after memory_docs): instance rows land in system.db, deliberately cleartext to the box owner, who is the intended reader (§2); the subject cannot see them structurally The pass reads the subject's database inside a supervisor's runtime, so the ephemeral session and run row land in the watcher's file; iteration is over subjects, so two parents watching one child get one review; and the subject need not be logged in when their space is unencrypted — via the new UserManager::open_unencrypted, which refuses an encrypted user outright (no key to be had) and never registers the pool as unlocked. The agent declares the new AgentMeta flag allow_tools: false, so its turn gets an empty tool registry — nothing for a prompt injection in the transcript to call — and produces its report as its final assistant message, read back from chat_history and parsed (NOTHING_TO_REPORT sentinel, no row on quiet days). chat_history::conversation_window is the transcript query; its four filters (non-ephemeral, depth 0, non-synthetic, non-empty) each guard a specific way the review would otherwise be wrong, and tool calls are absent by construction. Cadence is Run at (hour) rather than Interval — 4am local by default — with due-ness answered inside has_work against the coverage watermark, so a machine off for three days covers the whole stretch in one pass. Reports announce ReportCreated on the system bus (no subscriber yet). run_ephemeral_turn gains a per-pass system_substitutions map, which the review uses to hand the model the subject's profile under __SUBJECT_PROFILE__ — the system-context substitutions describe the session owner, the wrong person here. docs/system-agents.md gains the conversation review section; CLAUDE.md documents the scope, the tables and the tool-less design. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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 |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
73c720e9ef |
llm: restore request logging lost in the agent-loop migration
Nightly Build / build (push) Successful in 6m50s
The LLM-requests page has been empty since
|
||
|
|
a3e1b0add0 |
memory: reshape the two stores into a maintained wiki
Nightly Build / build (push) Successful in 6m50s
Memory was a scrapbook: notes accumulated, nothing kept them consistent, and shared memory had no rule saying what belonged in it. This adopts the LLM-wiki pattern — the assistant maintains an evolving artifact rather than re-deriving knowledge each session. The schema (agents/common/memory-wiki.md, included by the three type:chat agents) adds an append-only log.md beside each index.md, names Ingest / Recall / Lint as habits, and states the rule for shared memory: write it there only if you would say it out loud with every member in the room. One person's health, results or another member's opinion of them stays private — that is what shared folders, not shared memory, are for. Tampering is the reason the rules are shaped this way. Shared facts carry provenance and are superseded rather than erased, and a member who contradicts a fact they did not write gets a logged CLAIM instead of an overwrite: only the originator or an admin can turn it into a change. The approval gate cannot enforce this — it asks the caller, who is the same person pushing — so a per-role write permission on shared memory is still the real boundary. The prompt is the etiquette, not the fence. append_file is a new fs tool because the log needs a write that cannot shorten a file. On memory paths it is one SQL statement, so concurrent appends (parallel tool batches, two sessions of one user) cannot lose a line — a dropped line in an audit trail is worse than a failed write. It is auto-allowed on shared-memory/log.md at a lower priority than the shared write rule: gating the trail would be friction with no safety, and a rejected log write yields an unlogged change. memory::scaffold seeds index.md / log.md / user.md so the schema does not describe files that do not exist. Seeded empty rather than left absent: a missing note resolves to nothing at injection, so the model cannot tell "nothing recorded yet" from "this mechanism is not running". __MEMBERS__ renders the roster from users + roles instead of a note the model maintains. A remembered copy drifts and can be talked into being edited; this one bypasses the model entirely. users.notes are excluded — they are the admin's private notes about a person and this block is visible to every member. Still open: a UI to browse memory (list_dir does not classify memory paths yet), a tool-written revisions table (log lines are still typed by the model), and the periodic lint pass. |
||
|
|
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). |
||
|
|
882a8c9cb9 |
llm: switch Skald to agent-loop Model clients; drop llm-client (phase 1, D13)
The LLM call path now runs on the agent-loop crate's clients and trait: - core-api: BuiltLlmClient.client is Arc<dyn agent_loop::model::Model>; chatbot.rs (ChatbotClient + wire types) deleted; APP_NAME re-exported from agent-loop - providers (openai/anthropic/ollama/openrouter/requesty/declared) build OpenAiModel/AnthropicModel/OllamaModel with the model's wire id - LoggingModel decorator (llm/logging.rs) replaces LoggingChatbotClient; per-request correlation (session/stack/user) travels in the new ModelRequest.log field, never sent to providers - llm_call/llm_loop/compactor speak Model::complete + ModelResponse; retriability via Model::is_retriable (structured status, B6 rule now the crate's default); payload persistence reads RawMeta off ModelResponse/ModelError - crates/llm-client and skald-core/src/chatbot deleted Full workspace test suite green (incl. 162 skald-core + 32 agent-loop). |
||
|
|
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. |
||
|
|
d1d0a2af26 |
llm: add dynamic tool loading (DTL) — Kimi system-tools + Anthropic tool-reference
Nightly Build / build (push) Successful in 6m51s
Replace the old session_mcp_grants/stack_mcp_grants table pair with
a single activated_tools table that anchors each activation at the
assistant message_id that triggered it. The durable write moves from
the activate_tools tool itself to the round loop (handle_tool_call),
which has the message_id the DTL serializer positions injected tool
blocks against.
Introduce DtlMode (None / AnthropicToolReference / KimiSystemTools),
resolved per model from capabilities (opt-in via tool_search)
combined with the provider's dtl_format(). The message builder inserts
Kimi system {tools} blocks at the activation position, or emits
Anthropic tool_reference markers on the tool result. The tool-def
surface (all_tool_defs) switches shape: Anthropic declares everything
deferred; Kimi omits activated tools from the top-level array (system
takes over); None keeps the old grant-set logic.
Anthropic client: accept structured system arrays (cache_control on
the static block when DTL is active), carry defer_loading through
conversion, emit tool_reference blocks on result messages. Prompt
caching enabled exactly when DTL is active (anthropic provider).
MCP server list in the prompt is now a static catalogue (not split
Available/Active) — the split invalidated the cache on every activation.
Groundwork for providers.yaml dtl: key; Moonshot/Kimi providers wired
with kimi_system_tools and the k3* enrich now adds tool_search.
Compactor re-anchors activations whose message was compacted away.
|
||
|
|
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 |
||
|
|
aa4f31ec64 |
Fix memory-docs line-count SQL edge case, remove stale tmp-referencing test
Nightly Build / build (push) Successful in 6m46s
- memory_docs list_with_metadata: use SQL substr to detect trailing newline instead of counting newlines and adding 1 unconditionally - list_files: remove meta_smoke tests that referenced a non-existent scratchpad directory |
||
|
|
7769b6689d |
list_files with_metadata mode, deeper JSON outline, MCP connector descriptions in prompt
Nightly Build / build (push) Has been cancelled
- list_files: new with_metadata parameter returns {path, line_count?, size}
per entry (both disk and memory-docs) so the agent can spot large files
worth outlining before reading
- ast_outline: replaced flat tree-sitter JSON walker with a recursive one
that shows nested keys at every depth, with inline scalar values and
container summaries
- message_builder: format active MCP connectors as a table with description
instead of a bare bullet list
- read_file description now hints to use get_ast_outline first
- tools.md: agent guidance to outline before reading
|
||
|
|
cfaa7bace3 |
feat(read_file): let capable models view images, video and PDFs
Nightly Build / build (push) Successful in 6m38s
When the resolved model declares an input modality (vision → images,
video, document → PDFs), read_file now hands a binary media file back to
the model as native input instead of failing on non-UTF-8 bytes.
- ToolResult gains a Media { text, media } variant carrying MediaRef
{ host_path, mime }; the tool message keeps only the text note, the
bytes travel out of band in a new additive chat_llm_tools.media column
(mirrors preview_old/new).
- read_file sniffs the resolved host file; a recognized medium becomes a
Media result (with a neutral note), everything else keeps the textual
path. Capability gating lives in the message builder, so read_file
never needs the model caps and degrades cleanly on a text-only model.
- MessageBuilder inlines current-turn tool media as a synthetic user
message right after the tool-result group (media_turn_start boundary,
so older turns are never re-billed), reusing media.rs primitives via a
new inline_paths helper that contains against the caller's workspace
roots. OpenAI forwards the parts verbatim; the Anthropic client now
also translates the PDF `file` part into a native `document` block
(image_url → image was already handled).
- read_file's description is annotated per serving model in
call_llm_round, listing the formats it can open, so the model knows
reading one shows it the content.
Tests: media sniff (PDF), PDF file-part build, inline_paths containment
+ capability gating, capability hint, read_file media-vs-text, Anthropic
file→document, and the owner-schema-stands-alone check with the new
column.
|
||
|
|
c11702c3d3 |
tool card UI redesign: semantic icons, inline diff persistence, tool detail page, MCP-friendly titles
Nightly Build / build (push) Successful in 6m38s
|
||
|
|
8ff64cbddc |
rename agents/main→assistant, role-based default entry agent
Nightly Build / build (push) Successful in 6m31s
|
||
|
|
17f5769e0d |
mcp: per-user connector access control with deny-by-default grants
Nightly Build / build (push) Successful in 6m33s
|
||
|
|
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.
|
||
|
|
44dc67cda0 |
Setup: utente admin via web, ruoli e run-context con security-group, onboarding install/uninstall script
Nightly Build / build (push) Failing after 6m12s
|
||
|
|
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 |
||
|
|
f85876350e | feat(mcp): WhatsApp connector, archivable catalog, MCP connector config endpoint | ||
|
|
c389962e3c | Profilo utente (birthdate/sex/notes), kid agent, i18n, shared folders, UX | ||
|
|
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 |
||
|
|
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/ |
||
|
|
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. |
||
|
|
8dac783878 |
feat(container): per-user Docker sandbox + mapped per-user filesystem
Realizes blueprint §6: each user gets a permanent Docker container
(skald-{userid}, our own skald-runtime image with python+node) as their
execution sandbox. Docker is now a hard requirement — a missing daemon fails
Skald::new and the process exits at boot.
- ContainerManager (crates/skald-core/src/container/): docker availability
check, builds skald-runtime from the embedded Dockerfile, reconciles one
running container per active user at boot, stops them at shutdown, and
ensure/remove on user create/delete. Shells the docker CLI (no client crate).
- UserFs (core-api): pure value type carried in ToolContext, mapping the agent's
single namespace — ~/ → homes/{userid}, shared/{X}/ → shared/{X} (membership),
user-memory/ + shared-memory/ → SQLite — to host and container paths.
- execute_cmd now runs inside the caller's container via `docker exec`.
- fs-tools resolve every physical path through UserFs to the per-user host
workspace, host-side, with fail-closed symlink/`..` containment
(resolve_host_path: canonicalize + prefix-check). grep_files resolves its root
the same way but stays disk-only.
- shared_folders + shared_folder_members (registry, junction table with
can_write) back the shared-folder membership that drives both the container
mounts and the shared/{X} routing.
- Threading: UserContext.fs → ChatSessionManager → handler → ToolContext.fs.
Per-user MCP servers do not yet run in the container (next round).
|
||
|
|
a847dda88f |
feat(memory): dual-pool memory namespace, FTS search, and prompt injection
Add a virtual memory namespace backed by SQLite, surfaced through the fs-tools, with private (per-user) and shared (system) stores. Storage - `memory_docs` owner table + external-content FTS5 index with sync triggers. - `db/memory_docs.rs` accessor: get / upsert / list / search (bm25+snippet) / delete. Routing (tools/fs) - `classify_memory` splits paths on the raw first component; `..` clamps inside the store, never escaping to disk. - read/write/list/edit/insert/replace/search_file route `user-memory/` to the owner pool and `shared-memory/` to the system pool (a singleton captured in `register_all`); every other path stays on disk. Each tool extracts a pure transform shared between its disk and memory paths. - New `memory_search` tool over the FTS index (scope private/shared/all), with a sanitised FTS5 query. grep_files stays disk-only. Approval - `user-memory/*` allow (read+write); `shared-memory/*` reads allow, writes require approval so the agent can't silently push one person's data into shared memory. `memory_search` allowed via a path-less rule. - migrate away the old `memory/*` and blanket `shared-memory/*` rows. Prompt injection - `MessageBuilder::load_inject_memory` reads `user-memory/` (owner pool) and `shared-memory/` (system pool) inject entries from SQLite; disk paths unchanged. The system pool is threaded ChatSessionManager -> handler -> MessageBuilder. - main and project-coordinator inject `user-memory/index.md` + `shared-memory/index.md`; common/memory.md rewritten for the two stores. |
||
|
|
587958ffe0 | llm request tracking, user context cleanup, minor fixes | ||
|
|
7dd77d4ef4 |
feat(auth): login, roles, user mgmt, setup wizard, and session guard
- New skald-setup crate: interactive first-run wizard that creates the admin user, prompts for encryption choice and password - Auth system: session-based login/logout with cookie, guard middleware - Roles API: CRUD for data-driven roles, seeded on first boot - Users management API: create, list, edit, delete users - Setup state API: check if first admin has been created - Frontend: login-page, setup-page, users-page, roles-page, profile-page components with corresponding CSS - Topbar: avatar dropdown with profile link and logout - Sidebar: nav entries for Users and Roles (admin only) - Page shell CSS: layout support for the new pages - build.sh: builds both skald and skald-setup binaries - run.sh: runs skald-setup before the server loop - CLAUDE.md: updated workspace layout and build/run docs |
||
|
|
178a38357e |
feat(users): UserManager with per-user SQLCipher, and extract skald-core crate
Two changes developed together in one session; they share the same module
structure (db/mod.rs, the core lib root) and only compile together, so they
land as one commit.
## UserManager + per-user encryption (§9/§11)
New `users::UserManager`: owns the system.db pool plus a map
`userid -> SqlitePool` of unlocked databases. The pool *is* the unlock token —
its connect options carry the DEK as SQLCipher's raw key, so an open pool means
the key is in RAM until restart and dropping it re-locks (§9). Knows nothing
about cookies.
New `crypto` module: envelope encryption. A random 256-bit DEK encrypts
`{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under
`Argon2id(password, salt)`. The AEAD tag is the password verifier — one
derivation both authenticates and yields the key, so encrypted users store no
second hash. Cleartext users store the Argon2id output directly, compared in
constant time. Argon2 runs in spawn_blocking behind a 2-permit semaphore
(256 MiB per derivation).
- SQLCipher via `libsqlite3-sys` `bundled-sqlcipher-vendored-openssl`, pinned
<0.38 so it unifies with the one sqlx-sqlite links (a newer copy would apply
the feature to a SQLite sqlx never uses). OpenSSL is vendored and static, so
the binary stays self-contained.
- Schema split into `create_registry_tables` (instance-wide, no user key) and
`create_owner_tables` (one owner's content, identical in every file). No FK in
the owner bucket may reach the registry — enforced by a standalone test.
Dropped `chat_history.model_db_id` (write-only, and the only registry-crossing
key); moved `projects`/`project_tickets` into the owner bucket.
- Provisioning invariant: the file is written before the row, deleted after it,
so a crash leaves an orphan file, never a user without a database. `open_db`
never creates: a missing file is an error, not a silent empty database.
Not consumed yet: no login, call sites still use the shared system.db pool.
## Extract crates/skald-core
The headless core moves out of `src/` into its own crate; `skald` (server) and
the coming `skald-setup` are shells around it. Two dependencies on the shell
were inverted rather than dragged along, so the core names neither Tauri nor any
concrete plugin:
- `Plugin::tools(self: Arc<Self>)` — plugins contribute tools through this hook
(sibling of `http_router`), so the core no longer downcasts to
`MobileConnectorPlugin`.
- `tools::restart::set_restart_handler` — the desktop shell installs its
teardown-and-respawn; the core defaults to the supervisor exit code. The core
loses its `desktop` feature.
- `boot`'s stdout formatter moves to the binary (`src/boot_format.rs`); the core
only emits tracing events.
All 79 core tests pass; the binary boots and serves in a clean directory, and
the mobile-connector tools still register through the new hook.
|