From 44dc67cda0214e8b9ae153b7b4fd8e47e1a745d4 Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Mon, 20 Jul 2026 12:54:56 +0100 Subject: [PATCH] Setup: utente admin via web, ruoli e run-context con security-group, onboarding install/uninstall script --- .gitea/workflows/release.yml | 5 + CLAUDE.md | 8 +- SKALD.md | 129 ++++++++++---- ci/package.sh | 75 +++++--- crates/core-api/src/events.rs | 8 + crates/skald-core/src/chat_hub/mod.rs | 13 ++ crates/skald-core/src/db/roles.rs | 146 +++++++++++++++- crates/skald-core/src/lib.rs | 1 + crates/skald-core/src/run_context/mod.rs | 104 +++++++++++ crates/skald-core/src/setup/mod.rs | 159 +++++++++++++++++ crates/skald-setup/src/main.rs | 62 +++++-- install-nightly.sh | 195 +++++++++++++++++++++ install.sh | 210 +++++++++++++++++++++++ src/frontend/api/auth.rs | 19 +- src/frontend/api/guard.rs | 7 +- src/frontend/api/mod.rs | 3 + src/frontend/api/roles.rs | 9 + src/frontend/api/run_context.rs | 75 +++++++- src/frontend/api/sessions.rs | 25 ++- src/frontend/api/setup.rs | 55 ++++-- src/frontend/api/ws.rs | 103 +++++++++++ uninstall.sh | 111 ++++++++++++ web/components/copilot.js | 26 +++ web/components/roles-page.js | 39 ++++- web/components/setup-page.js | 37 ++++ web/i18n/en.js | 6 +- web/i18n/fr.js | 6 +- web/i18n/it.js | 6 +- web/lib/chat-session.js | 99 ++++++++++- 29 files changed, 1631 insertions(+), 110 deletions(-) create mode 100644 crates/skald-core/src/setup/mod.rs create mode 100755 install-nightly.sh create mode 100755 install.sh create mode 100644 uninstall.sh diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 1a48f96..2244b81 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -86,3 +86,8 @@ jobs: cp dist/*.tar.gz "$TARGET/" echo "[release] Deployed $VERSION:" ls -lh "$TARGET/" + + - name: Update latest version pointer + run: | + echo "${{ steps.extract-version.outputs.version }}" > /var/www/builds.skaldagent.net/releases/LATEST + echo "[release] Updated releases/LATEST → ${{ steps.extract-version.outputs.version }}" diff --git a/CLAUDE.md b/CLAUDE.md index f7bcad0..79d1f93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ The application core is the `skald-core` crate; the binaries are **shells** arou | ---- | ---- | | `crates/skald-core/` | Storage, identity, crypto, LLM stack, tools, MCP, sessions. Knows nothing about what runs it: no HTTP server and **no concrete plugin crate** — `PluginManager` only ever sees `Arc` from `core-api` | | `skald` (root, `src/`) | The server shell: `main.rs`, the Axum `frontend/`, `config.rs`. Constructs the plugin list and hands it to `Skald::new`. Runs headless as a background daemon under the `run.sh` supervisor | -| `crates/skald-setup/` | Guided first-run setup — a terminal shell over `skald-core`. Creates the first admin via `UserManager::register_user` (asking interface language, whether to encrypt — default yes — and password). The chosen language becomes the instance default (`ui_locale`). A separate binary so the server never links TTY-prompt deps, and so a future GUI installer is a third shell over the same `UserManager`. `run.sh` runs it before the server loop; it prompts only when `users` is empty **and** stdin is a terminal, otherwise a no-op. `--check` reports readiness by exit code (0 done, 1 needed) | +| `crates/skald-setup/` | Guided first-run setup — a terminal shell over `skald-core`. Creates the first admin and seeds the instance through the **shared seam `skald_core::setup::initialize_instance`** (apply the chosen seed profile → `register_user(admin)` → set default locale) — the *same* function the web setup calls, so the two shells can't drift. Asks profile, interface language, whether to encrypt — default yes — and password. A separate binary so the server never links TTY-prompt deps, and so a future GUI installer is a third shell over the same seam. `run.sh` runs it before the server loop; it prompts only when `users` is empty **and** stdin is a terminal, otherwise a no-op. `--check` reports readiness by exit code (0 done, 1 needed) | | `crates/core-api/` | The contracts both sides share: `Plugin`, `Tool`, event buses, provider types | Two rules keep the boundary real, and both are enforced by the compiler: @@ -118,7 +118,7 @@ Schema is greenfield (no migrations, §0), but a purely **additive** column land `system.db` still gets **both** bucket functions — but no longer because the migration is unstarted. It gets the owner schema because it *is* the owner of **shared** memory (`memory_docs`) plus, for now, the globally-scoped `secrets` and the `mcp_events` lifecycle log (`SecretsStore` and the global `McpManager` are built on the system pool and shared by reference into every `UserContext`; the global runtime's *config* now lives in the registry table `mcp_global_servers`, and per-user connector config in each user's owner `mcp_user_servers`). Every *other* owner table is created there but never written to anymore — the global owner-bound managers that would write them (chat/jobs/etc.) are inert (see "Current state"). Fully dropping `create_owner_tables` from `system.db` is blocked on the §4 scope decision for secrets (plus the residual global `mcp_events` log), not on call-site migration. -`users` (`crates/skald-core/src/db/users.rs`) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. `Credentials` is an enum mirroring the table's `CHECK`: an encrypted user carries a **wrapped DEK** (whose AEAD tag *is* the password verifier — hence no `password_hash`); a cleartext user carries an ordinary verifier, or none. `User` is deliberately not `Serialize` and its `Debug` redacts key material — use `User::summary()` for anything leaving the process. `role_id` references `roles(id)` (the `roles` table is now seeded before `users` in `create_registry_tables`). A nullable `locale` column (additive via `ensure_column`) holds the per-user UI language override; role-driven UI conventions live in the free-form `roles.attrs` JSON (e.g. `ui_mode`, see the frontend section) — never new columns per attribute. The admin-managed **directory profile** lives in three more additive columns — `birthdate` (ISO `YYYY-MM-DD`), `sex` (free text), `notes` (admin-authored) — edited only from the Users admin page (`set_directory_fields`; validation — real non-future date, length caps — lives in the `users_mgmt` API, not the db layer) and rendered into agent prompts by the `__USER_PROFILE__` substitution (see above). They are directory metadata written *by* the admin *about* the user, so the registry is their honest home under the §2 threat model. +`users` (`crates/skald-core/src/db/users.rs`) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. `Credentials` is an enum mirroring the table's `CHECK`: an encrypted user carries a **wrapped DEK** (whose AEAD tag *is* the password verifier — hence no `password_hash`); a cleartext user carries an ordinary verifier, or none. `User` is deliberately not `Serialize` and its `Debug` redacts key material — use `User::summary()` for anything leaving the process. `role_id` references `roles(id)` (the `roles` table is now seeded before `users` in `create_registry_tables`). A nullable `locale` column (additive via `ensure_column`) holds the per-user UI language override; role-driven conventions live in the free-form `roles.attrs` JSON — never new columns per attribute — parsed at a **single point** by the typed `db::roles::RoleAttrs` (`ui_mode`, `permission_groups`): `ui_mode` (see the frontend section) plus the role's **security-group set** (`roles.permission_group` = the default group, `attrs.permission_groups` = additional allowed groups; `Role::effective_groups()` = the union, `roles::role_allows_group()` gates it with `admin` short-circuiting to all). See the security-group picker in the frontend section. The admin-managed **directory profile** lives in three more additive columns — `birthdate` (ISO `YYYY-MM-DD`), `sex` (free text), `notes` (admin-authored) — edited only from the Users admin page (`set_directory_fields`; validation — real non-future date, length caps — lives in the `users_mgmt` API, not the db layer) and rendered into agent prompts by the `__USER_PROFILE__` substitution (see above). They are directory metadata written *by* the admin *about* the user, so the registry is their honest home under the §2 threat model. ## Filesystem & containers (blueprint §6) @@ -270,7 +270,9 @@ All extend `LightElement` from `web/lib/base.js` (Lit). `ChatSession` (`web/lib/ **Plugin & backend i18n** — two seams, both keyed the same way. A plugin **page fragment** (served from its own router) localizes client-side: it ships a `web/i18n.js` module (`export default { en, it, fr }`, keys namespaced `plugin..`) and calls `addStrings(dicts)` (in `web/lib/i18n.js`) once at module load to merge into the host's shared `DICTS`, then uses the same `t()`/`I18nMixin` as the app (the fragment imports them from the absolute `/lib/i18n.js` — the *same* module instance the host uses, so `t()` and `locale-changed` are shared; no endpoint, no per-locale fetch — all locales ride in the fragment, so a language switch is instant). Mobile-connector is the reference: `common.js` registers the dict + re-exports `t`, and `MobileBase extends I18nMixin(LitElement)`. **Backend-generated strings** (a plugin's HTTP error/response text, notifications) go through `core_api::i18n`: a plugin declares `Plugin::i18n() -> Vec` (mobile-connector loads them from embedded `i18n/{en,it,fr}.json` via `include_str!`), the `PluginManager` merges every plugin's bundles once at boot into an `I18nCatalog` (`skald_core::i18n`) and injects it as `PluginContext.i18n: Arc`. At request time the handler resolves the caller (`Caller.user_id` from the auth layer) and calls `i18n.for_user(user_id, key, args).await` — which reads `users.locale`, runs it through the same `resolve_locale` chain, and renders `locale → en → key` with `{name}` placeholders. The frontend surfaces these already-translated: `jf()` throws the server's response text verbatim. Front and back keep **separate** tables (UI labels ≠ error strings; overlap is minimal) but share the `plugin..` namespace convention. The mechanism is general (any plugin, and eventually the core, registers the same way); only mobile-connector uses it so far. -**Role-driven interface** (§0.1 — data, not enums): `roles.attrs` JSON may carry `"ui_mode": "simple"`. `/api/auth/me` resolves it (`admin` is always `full`) and the sidebar renders chat + inbox only for simple-mode members; the role editor exposes it as an "Interface" select. Hiding links is never access control — routes stay capability-gated server-side. `MeResponse` also carries `locale`, `default_locale` and `encrypted`. +**Role-driven interface** (§0.1 — data, not enums): `roles.attrs` JSON may carry `"ui_mode": "simple"`. `/api/auth/me` resolves it via `RoleAttrs` (`admin` is always `full`) and the sidebar renders chat + inbox only for simple-mode members; the role editor exposes it as an "Interface" select. Hiding links is never access control — routes stay capability-gated server-side. `MeResponse` also carries `locale`, `default_locale` and `encrypted`. + +**Security-group picker (per-session, runtime, role-gated).** A **security-group** is a *permission bundle only* — a `tool_permission_groups` id, driving tool visibility/approval — **not** a "mode" (no system-context injection; the `RunContext.system_prompt` substrate exists but is unused by the picker). The role carries the user's **allowed set** (default `permission_group` + `attrs.permission_groups`, §0.1); a new non-project session inherits the role's default group (`sessions.rs::create` → `role_default_run_context`). The chat surface switches it **at runtime like the model pill**: `copilot.js` renders a shield pill (hidden when ≤1 group) fed by `GET /api/my/security-groups` (the caller's role set, joined with group names; `admin` → all); selecting one sends the WS control message `{type:"select_security_group", group}` (`chat-session.js::_selectGroup`, twin of `select_client`). The server (`ws.rs::handle_select_security_group_msg`) validates against the role, persists it on `chat_sessions.run_context`, updates the live handler, and **broadcasts `ServerEvent::SecurityGroupSelected`** so every open tab re-syncs (the initial state is sent on WS connect). **Enforcement is server-side** via the shared `run_context::validate_run_context_for_role` (used by both the WS path and the REST `set_session_run_context`): a non-admin may only pick a group in its role's effective set (else 403), and **every other `RunContext` field** (`system_prompt`, `allow_fs_writes`/`allow_fs_reads`, `working_directory`) is **discarded** — closing an fs-escalation hole; `admin` passes through unchanged. The role editor (`roles-page.js`) sets the default group + an allowed-groups checklist (→ `attrs.permission_groups`). | File | Element | Notes | | ---- | ------- | ----- | diff --git a/SKALD.md b/SKALD.md index b74407c..d168e78 100644 --- a/SKALD.md +++ b/SKALD.md @@ -1,56 +1,115 @@ # Skald Circle — SKALD -## Stato attuale +## Current status -Progetto nuova applicazione con agenti e chatbot per aiutare famiglie e piccoli gruppi a collaborare, con chat supervisionato per bambini/persone vulnerabili. +New application with agents and chatbots to help families and small groups collaborate, with supervised chat for children and vulnerable people. -### Icone agenti — completate ✅ +### Agent icons — completed ✅ -Tutti gli 11 agenti hanno ora icone in stile **Vector Paintings** (painterly vector, caldo e family-friendly), generate via ComfyUI: +All 11 agents now have **Vector Paintings** icons (painterly vector, warm and family-friendly), generated via ComfyUI: -| Agente | Animale | Stato | -|--------|---------|-------| -| Main Assistant | 🦊 Volpe | ✅ | -| Project Coordinator | 🦡 Tasso | ✅ | -| Researcher | 🐿️ Scoiattolo | ✅ | -| Generalist | 🦫 Castoro | ✅ | +| Agent | Animal | Status | +|-------|--------|--------| +| Main Assistant | 🦊 Fox | ✅ | +| Project Coordinator | 🦡 Badger | ✅ | +| Researcher | 🐿️ Squirrel | ✅ | +| Generalist | 🦫 Beaver | ✅ | | Code Explorer | 🕵️ Meerkat | ✅ | -| Software Architect | 🏗️ Airone | ✅ | -| Software Engineer | 🔧 Orso | ✅ | -| Spec Writer | 📝 Gufo | ✅ | -| Tech Lead | 👑 Cervo | ✅ | -| TIC | 👁️ Gatto | ✅ | -| Business Analyst | 💼 Gazza | ✅ | +| Software Architect | 🏗️ Heron | ✅ | +| Software Engineer | 🔧 Bear | ✅ | +| Spec Writer | 📝 Owl | ✅ | +| Tech Lead | 👑 Deer | ✅ | +| TIC | 👁️ Cat | ✅ | +| Business Analyst | 💼 Magpie | ✅ | -### Refactoring — completato ✅ +### Refactoring — completed ✅ -- Rimossa dipendenza da Tauri/desktop (`tauri.conf.json`, `src/desktop/`, `icons/`, `docs/desktop.md`, schemi gen/) -- Rimosso `build.rs` (non più necessario) -- Nuovo sistema i18n (core-api + plugin-mobile-connector + web) -- Refactoring sistema di configurazione +- Removed Tauri/desktop dependency (`tauri.conf.json`, `src/desktop/`, `icons/`, `docs/desktop.md`, gen schemas/) +- Removed `build.rs` (no longer needed) +- New i18n system (core-api + plugin-mobile-connector + web) +- Configuration system refactoring ### Auto-build CI/CD ✅ -Build automatica su NiPoGi con Gitea Actions (runner nativo v2.1.0): +Automatic build on NiPoGi with Gitea Actions (native runner v2.1.0): -| Componente | File | Stato | +| Component | File | Status | |---|---|---| -| `scripts/package.sh` | Crea tarball distributivi da binari compilati | ✅ | -| `scripts/verify-version.sh` | Verifica che una release non sia già buildata | ✅ | -| `.gitea/workflows/nightly.yml` | Push su `main` → build amd64+arm64 → nightly/ | ✅ | +| `ci/package.sh` | Creates distribution tarballs from compiled binaries | ✅ | +| `ci/verify-version.sh` | Verifies that a release hasn't been built yet | ✅ | +| `.gitea/workflows/nightly.yml` | Push to `main` → build amd64+arm64 → nightly/ | ✅ | | `.gitea/workflows/release.yml` | PR check `verify-version` + merge → build → releases/v{ver}/ | ✅ | -| **act_runner** nativo su NiPoGi | v2.1.0, host-mode systemd service | ✅ | +| **Native runner** on NiPoGi | v2.1.0, host-mode systemd service, label `linux-amd64` | ✅ | | **Cross toolchain** (arm64) | `gcc-aarch64-linux-gnu` + `rustup target add` | ✅ | -| **Caddy `builds.skaldagent.net`** | Configurato + directory `/var/www/builds.skaldagent.net/` | ✅ | +| **Caddy `builds.skaldagent.net`** | file_server browse (directory listing) | ✅ | | **Route53 `builds.skaldagent.net`** | A record → 145.40.169.107 | ✅ | -| **`install.sh`** | Script one-liner `curl ... | bash` | ⏳ Da creare | +| **CI cache** | Persistent `CARGO_TARGET_DIR` at `/home/dguiducci/.cache/skald-ci/target` | ✅ | +| **`install.sh`** | One-liner script `curl ... | bash` — Linux (systemd) + macOS ARM64 (launchd) | ✅ | +| **`install-nightly.sh`** | One-liner script for nightly builds — same OS support | ✅ | +| **`uninstall.sh`** | Bundled in tarball — stops service/agent, removes everything | ✅ | +| **`releases/LATEST`** | Auto-updated by release workflow to track latest version | ✅ | -### Prossimi passi +### Technical notes -- Creare branch `release` su Gitea con branch protection (PR via UI) -- Testare il workflow con una PR su `release` -- Creare `install.sh` per installazione one-liner +- `scripts/` in `.gitignore` — CI scripts moved to `ci/` (tracked by git) +- Build without `whisper-local` on Linux (`--no-default-features`) +- `aarch64-linux-gnu-strip` for ARM64 binaries +- `actions/checkout@v4` works (native runner has Node.js) +- macOS ARM64 supported via `install.sh` / `install-nightly.sh` (auto-detects OS, uses launchd) -### Future ideas (TODO) +### Next steps -- **One-liner install**: sito web con comando bash da copiare-incollare su macOS/Linux che fa installazione automatica +- Create `release` branch on Gitea with branch protection (PR via UI) +- Test release workflow with a PR +- Build first macOS ARM64 binary on MacBook, upload to `builds.skaldagent.net` + +### macOS support + +**Supported**: macOS ARM64 (Apple Silicon M1+), Intel not supported. + +| Aspect | Status | Notes | +|--------|--------|-------| +| **Install script** (`install.sh`) | ✅ | Auto-detects macOS, uses launchd | +| **Nightly install** (`install-nightly.sh`) | ✅ | Same logic | +| **Uninstall script** (`uninstall.sh`) | ✅ | Handles launchctl | +| **Package script** (`ci/package.sh`) | ✅ | Accepts `--os darwin`, strips best-effort | +| **Binary** | ⏳ Not yet built | Build natively on MacBook, deploy to builds.skaldagent.net | + +#### How to build for macOS (on MacBook) + +```sh +cargo build --release -p skald-setup -p skald # includes whisper +./ci/package.sh --version v0.1.0 --os darwin --arch arm64 \ + --target-dir target/release --output dist/ +``` + +Upload the resulting `dist/skald-circle-v0.1.0-darwin-arm64.tar.gz` to the NiPoGi's `builds.skaldagent.net/releases/v0.1.0/` directory. + +#### Cross-compilation from NiPoGi (research notes 🧪) + +Cross-compiling for `aarch64-apple-darwin` from the NiPoGi using **zig** + **cargo-zigbuild** was attempted but hit blockers. + +| Component | Location | Notes | +|-----------|----------|-------| +| **Zig** | `~/.local/bin/zig` (symlink to `/tmp/zig-linux-x86_64-0.14.0/zig`) | v0.14.0, installed manually | +| **macOS SDK** | `/opt/MacOSX/MacOSX11.3.sdk` | From `phracker/MacOSX-SDKs` (GitHub) | +| **Rust targets** | `aarch64-apple-darwin` | via `rustup target add` | +| **`cargo-zigbuild`** | `~/.cargo/bin/cargo-zigbuild` | v0.23.0 | +| **zig wrapper scripts** | `/tmp/zig-wrap-cxx.sh`, `/tmp/zig-ar-wrap.sh` | Handle OpenSSL/Clang flags + SDK paths | + +**What works:** +- ✅ Rust std compilation for macOS target +- ✅ OpenSSL compilation from source (via wrapper that remaps `--target=` and provides SDK headers) +- ✅ Rust dependency compilation (tree-sitter, sqlx, tokio, etc.) +- ✅ Single-file C programs compile and link correctly + +**What's blocked:** +- ❌ `zig cc` segfaults with `-F` (framework search path) on Linux → can't link against macOS frameworks (CoreFoundation, Security) +- ❌ `zig cc` can't find frameworks without `-F` +- ❌ `libsqlite3-sys` build.rs bug: `is_apple` checks `host.contains("apple") && target.contains("apple")` → forces OpenSSL linkage instead of CommonCrypto on cross-compile (needs upstream fix or `OPENSSL_DIR` workaround) + +**The fix would be:** +1. Upstream fix to `libsqlite3-sys` build.rs (`target.contains("apple")` only) +2. Zig fix for `-F` segfault, or use `ld64` instead of zig's linker + +**Conclusion**: Cross-compilation is fragile. Build natively on MacBook for now. diff --git a/ci/package.sh b/ci/package.sh index c168b24..6547001 100755 --- a/ci/package.sh +++ b/ci/package.sh @@ -2,21 +2,22 @@ # Package a Skald Circle build into a distributable tarball. # # Usage: -# ./scripts/package.sh \ +# ./ci/package.sh \ # --version v0.1.0 \ +# --os linux \ # --arch amd64 \ # --target-dir target/release \ # --output /tmp/dist # -# --version Version string, e.g. "v0.1.0" or "nightly" -# --arch Architecture: "amd64" or "arm64" -# --target-dir Path to cargo release output (target/release or -# target/aarch64-unknown-linux-gnu/release) -# --output Directory where the .tar.gz will be written +# --version Version string, e.g. "v0.1.0" or "nightly" +# --os Target OS: "linux" or "darwin" +# --arch Architecture: "amd64" or "arm64" +# --target-dir Path to cargo release output +# --output Directory where the .tar.gz will be written # -# The tarball contains everything needed to run Skald Circle: +# The tarball contains everything needed to run (or uninstall) Skald Circle: # bin/skald, bin/skald-setup, web/, agents/, skills/, -# default.config.yaml, requirements.txt, run.sh +# default.config.yaml, requirements.txt, run.sh, uninstall.sh set -eu @@ -24,6 +25,7 @@ cd "$(dirname "$0")/.." # ── Parse args ──────────────────────────────────────────────────────────────── VERSION="" +OS="" ARCH="" TARGET_DIR="" OUTPUT="" @@ -31,6 +33,7 @@ OUTPUT="" while [ $# -gt 0 ]; do case "$1" in --version) VERSION="$2"; shift 2 ;; + --os) OS="$2"; shift 2 ;; --arch) ARCH="$2"; shift 2 ;; --target-dir) TARGET_DIR="$2"; shift 2 ;; --output) OUTPUT="$2"; shift 2 ;; @@ -38,18 +41,23 @@ while [ $# -gt 0 ]; do esac done -if [ -z "$VERSION" ] || [ -z "$ARCH" ] || [ -z "$TARGET_DIR" ] || [ -z "$OUTPUT" ]; then +if [ -z "$VERSION" ] || [ -z "$OS" ] || [ -z "$ARCH" ] || [ -z "$TARGET_DIR" ] || [ -z "$OUTPUT" ]; then echo "[package.sh] Missing required argument. See usage." >&2 exit 1 fi -PACKAGE_NAME="skald-circle-${VERSION}-linux-${ARCH}" +case "$OS" in + linux|darwin) ;; + *) echo "[package.sh] Unsupported OS: $OS (use linux or darwin)" >&2; exit 1 ;; +esac + +PACKAGE_NAME="skald-circle-${VERSION}-${OS}-${ARCH}" STAGING="$(mktemp -d)/${PACKAGE_NAME}" mkdir -p "$STAGING/bin" echo "[package.sh] Packaging $PACKAGE_NAME" -echo "[package.sh] target-dir: $TARGET_DIR" -echo "[package.sh] output: $OUTPUT" +echo "[package.sh] target-dir: $TARGET_DIR" +echo "[package.sh] output: $OUTPUT" # ── Verify binaries exist ───────────────────────────────────────────────────── if [ ! -f "$TARGET_DIR/skald" ]; then @@ -61,15 +69,23 @@ if [ ! -f "$TARGET_DIR/skald-setup" ]; then exit 1 fi -# ── Copy binaries (stripped) ────────────────────────────────────────────────── -if [ "$ARCH" = "arm64" ]; then - STRIP="aarch64-linux-gnu-strip" -else - STRIP="strip" -fi +# ── Copy binaries (stripped, best-effort on darwin) ─────────────────────────── cp "$TARGET_DIR/skald" "$STAGING/bin/skald" cp "$TARGET_DIR/skald-setup" "$STAGING/bin/skald-setup" -$STRIP "$STAGING/bin/skald" "$STAGING/bin/skald-setup" + +if [ "$OS" = "darwin" ]; then + # On macOS: strip via xcrun or the system strip (skip if cross-compiled) + if command -v xcrun >/dev/null 2>&1; then + xcrun strip "$STAGING/bin/skald" "$STAGING/bin/skald-setup" 2>/dev/null || true + elif command -v strip >/dev/null 2>&1; then + strip "$STAGING/bin/skald" "$STAGING/bin/skald-setup" 2>/dev/null || true + fi +elif [ "$ARCH" = "arm64" ]; then + STRIP="aarch64-linux-gnu-strip" + $STRIP "$STAGING/bin/skald" "$STAGING/bin/skald-setup" +else + strip "$STAGING/bin/skald" "$STAGING/bin/skald-setup" +fi chmod 755 "$STAGING/bin/skald" "$STAGING/bin/skald-setup" # ── Copy runtime assets ─────────────────────────────────────────────────────── @@ -79,7 +95,8 @@ cp -r skills "$STAGING/skills" cp default.config.yaml "$STAGING/default.config.yaml" cp requirements.txt "$STAGING/requirements.txt" cp run.sh "$STAGING/run.sh" -chmod 755 "$STAGING/run.sh" +cp uninstall.sh "$STAGING/uninstall.sh" +chmod 755 "$STAGING/run.sh" "$STAGING/uninstall.sh" # ── Create tarball ──────────────────────────────────────────────────────────── mkdir -p "$OUTPUT" @@ -91,7 +108,17 @@ cd - > /dev/null rm -rf "$(dirname "$STAGING")" -SHA256="$(sha256sum "$TARBALL" | cut -d' ' -f1)" -echo "[package.sh] ✅ Created $TARBALL" -echo "[package.sh] sha256: $SHA256" -echo "[package.sh] size: $(du -h "$TARBALL" | cut -f1)" +if command -v sha256sum >/dev/null 2>&1; then + SHA256="$(sha256sum "$TARBALL" | cut -d' ' -f1)" + echo "[package.sh] ✅ Created $TARBALL" + echo "[package.sh] sha256: $SHA256" + echo "[package.sh] size: $(du -h "$TARBALL" | cut -f1)" +elif command -v shasum >/dev/null 2>&1; then + SHA256="$(shasum -a 256 "$TARBALL" | cut -d' ' -f1)" + echo "[package.sh] ✅ Created $TARBALL" + echo "[package.sh] sha256: $SHA256" + echo "[package.sh] size: $(du -h "$TARBALL" | cut -f1)" +else + echo "[package.sh] ✅ Created $TARBALL" + echo "[package.sh] size: $(du -h "$TARBALL" | cut -f1)" +fi diff --git a/crates/core-api/src/events.rs b/crates/core-api/src/events.rs index 0ef7537..f4af363 100644 --- a/crates/core-api/src/events.rs +++ b/crates/core-api/src/events.rs @@ -238,6 +238,13 @@ pub enum ServerEvent { ClientSelected { client: String, }, + /// The session security-group (permission group) changed. Broadcast to every + /// client of the source so the chat picker stays in sync — the twin of + /// `ClientSelected` for the model. `group` is the effective group id + /// (`"default"` when cleared). The backend is the single source of truth. + SecurityGroupSelected { + group: String, + }, } impl ServerEvent { @@ -275,6 +282,7 @@ impl ServerEvent { Self::UserMessage { .. } => "user_message", Self::TurnRunning { .. } => "turn_running", Self::ClientSelected { .. } => "client_selected", + Self::SecurityGroupSelected { .. } => "security_group_selected", } } } diff --git a/crates/skald-core/src/chat_hub/mod.rs b/crates/skald-core/src/chat_hub/mod.rs index 3f6c42e..0d061ee 100644 --- a/crates/skald-core/src/chat_hub/mod.rs +++ b/crates/skald-core/src/chat_hub/mod.rs @@ -343,6 +343,19 @@ impl ChatHub { Some(sid) => sid, None => return Ok(()), // no prior session, nothing to resume }; + // Guard against double-driving. A client sends `resume` on connect whenever + // history shows a pending/interrupted tool — including when the turn is still + // live and merely awaiting an approval. Without this check `resume_turn` would + // block on the `processing` lock and, once the approval unblocks the original + // turn and it finishes, run a spurious *second* turn on the just-completed + // conversation. If a turn is already in flight it owns the session and emits + // its own events, so there is nothing to resume — skip. + if let Ok(handler) = self.session_handler(source_id).await { + if handler.is_processing() { + info!(source_id, "ChatHub::resume: turn already in flight — skipping resume"); + return Ok(()); + } + } self.resume_session(session_id).await } diff --git a/crates/skald-core/src/db/roles.rs b/crates/skald-core/src/db/roles.rs index eee28c9..ad858da 100644 --- a/crates/skald-core/src/db/roles.rs +++ b/crates/skald-core/src/db/roles.rs @@ -1,5 +1,5 @@ use anyhow::{Result, bail}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use sqlx::SqlitePool; /// The built-in admin role — immutable from the API. @@ -20,6 +20,81 @@ fn from_raw((id, label, permission_group, attrs, created_at): RawRow) -> Role { Role { id, label, permission_group, attrs, created_at } } +// ── Typed view over `roles.attrs` (§0.1: role attributes live in free-form JSON, +// never per-attribute columns) ──────────────────────────────────────────────── + +/// Interface mode a role opts into. `full` unless the role explicitly chooses the +/// simplified UI; `admin` is resolved to `full` upstream. Values other than the two +/// known ones fall back to `full` (tolerant parse). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum UiMode { + #[default] + Full, + Simple, +} + +impl UiMode { + pub fn as_str(self) -> &'static str { + match self { + UiMode::Full => "full", + UiMode::Simple => "simple", + } + } +} + +/// Typed parse of `roles.attrs`. The **single** place that reads the attrs JSON, so +/// scattered `serde_json::Value.get(...)` calls don't drift. Tolerant: any parse +/// error or missing key yields defaults. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct RoleAttrs { + pub ui_mode: UiMode, + /// Security-groups (`tool_permission_groups` ids) this role may use **in addition** + /// to its default `permission_group`. The default is always implicitly allowed; the + /// effective set is `unique({permission_group} ∪ permission_groups)`. + pub permission_groups: Vec, +} + +impl RoleAttrs { + pub fn from_opt(attrs: &Option) -> RoleAttrs { + attrs + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default() + } +} + +impl Role { + pub fn attrs_parsed(&self) -> RoleAttrs { + RoleAttrs::from_opt(&self.attrs) + } + + /// The security-groups this role may select: its default first, then any extras + /// from `attrs.permission_groups`, deduped. + pub fn effective_groups(&self) -> Vec { + let mut out = vec![self.permission_group.clone()]; + for g in self.attrs_parsed().permission_groups { + if !out.contains(&g) { + out.push(g); + } + } + out + } +} + +/// Whether a role may use `group_id` as its session security-group. `admin` holds +/// every group by construction; a missing role allows nothing. +pub async fn role_allows_group(pool: &SqlitePool, role_id: &str, group_id: &str) -> Result { + if role_id == ADMIN_ROLE_ID { + return Ok(true); + } + match get(pool, role_id).await? { + Some(role) => Ok(role.effective_groups().iter().any(|g| g == group_id)), + None => Ok(false), + } +} + // ── Reads ──────────────────────────────────────────────────────────────────── pub async fn list(pool: &SqlitePool) -> Result> { @@ -122,3 +197,72 @@ pub async fn seed_admin(pool: &SqlitePool) -> Result<()> { .await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp_db(tag: &str) -> String { + let dir = std::env::temp_dir().join(format!("skald-roles-{tag}-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join("system.db").to_str().unwrap().to_string() + } + + fn role(permission_group: &str, attrs: Option<&str>) -> Role { + Role { + id: "member".into(), + label: "Member".into(), + permission_group: permission_group.into(), + attrs: attrs.map(str::to_string), + created_at: String::new(), + } + } + + #[test] + fn role_attrs_are_tolerant() { + // Missing → defaults. + let a = RoleAttrs::from_opt(&None); + assert_eq!(a.ui_mode, UiMode::Full); + assert!(a.permission_groups.is_empty()); + + // Populated. + let a = RoleAttrs::from_opt(&Some( + r#"{"ui_mode":"simple","permission_groups":["ops","research"]}"#.into(), + )); + assert_eq!(a.ui_mode, UiMode::Simple); + assert_eq!(a.permission_groups, vec!["ops", "research"]); + + // Malformed JSON → defaults, never an error. + let a = RoleAttrs::from_opt(&Some("not json".into())); + assert_eq!(a.ui_mode, UiMode::Full); + assert!(a.permission_groups.is_empty()); + } + + #[test] + fn effective_groups_prepends_default_and_dedups() { + let r = role("default", Some(r#"{"permission_groups":["ops","default","research"]}"#)); + assert_eq!(r.effective_groups(), vec!["default", "ops", "research"]); + + // No extras → just the default. + let r = role("kids", None); + assert_eq!(r.effective_groups(), vec!["kids"]); + } + + #[tokio::test] + async fn role_allows_group_admin_member_and_unknown() { + let pool = crate::db::init_system_pool(&tmp_db("allows")).await.unwrap(); + + // admin is seeded by init and allows any group by construction. + assert!(role_allows_group(&pool, ADMIN_ROLE_ID, "anything").await.unwrap()); + + insert(&pool, "member", "Member", "default", Some(r#"{"permission_groups":["ops"]}"#)) + .await + .unwrap(); + assert!(role_allows_group(&pool, "member", "default").await.unwrap()); + assert!(role_allows_group(&pool, "member", "ops").await.unwrap()); + assert!(!role_allows_group(&pool, "member", "research").await.unwrap()); + + // An unknown role allows nothing. + assert!(!role_allows_group(&pool, "ghost", "default").await.unwrap()); + } +} diff --git a/crates/skald-core/src/lib.rs b/crates/skald-core/src/lib.rs index 57d9830..6c20a0f 100644 --- a/crates/skald-core/src/lib.rs +++ b/crates/skald-core/src/lib.rs @@ -41,6 +41,7 @@ pub mod run_context; pub mod secrets; pub mod service_manager; pub mod session; +pub mod setup; pub mod tic; pub mod tool_catalog; pub mod tool_discovery; diff --git a/crates/skald-core/src/run_context/mod.rs b/crates/skald-core/src/run_context/mod.rs index ec8b3d0..f75ef01 100644 --- a/crates/skald-core/src/run_context/mod.rs +++ b/crates/skald-core/src/run_context/mod.rs @@ -100,6 +100,53 @@ impl RunContext { } } +/// Outcome of validating a client-supplied [`RunContext`] against the caller's role. +pub enum RunContextDecision { + /// Apply this (possibly sanitized) run-context to the session. + Apply(Option), + /// The requested security-group is not in the role's allowed set (→ 403); the + /// string is the offending group id. + Forbidden(String), +} + +/// Gate a client-supplied run-context by the caller's role, closing two holes at +/// once (§0.1 — enforce server-side, never trust the client): +/// +/// - **Group governance**: a non-admin may only select a security-group in its +/// role's effective set ([`crate::db::roles::role_allows_group`]); anything else +/// is [`RunContextDecision::Forbidden`]. +/// - **fs escalation**: for a non-admin every other `RunContext` field +/// (`system_prompt`, `allow_fs_writes`/`allow_fs_reads`, `working_directory`) is +/// **discarded** — the client can set the permission group, nothing more. A rich +/// run-context (a project's) is resolved server-side, never through this path. +/// +/// `admin` is trusted and passes through unchanged. `None` (clear) is always +/// allowed and falls back to the role's default group at session build. +pub async fn validate_run_context_for_role( + registry_pool: &SqlitePool, + role_id: &str, + incoming: Option, +) -> Result { + if role_id == crate::db::roles::ADMIN_ROLE_ID { + return Ok(RunContextDecision::Apply(incoming)); + } + let Some(rc) = incoming else { + return Ok(RunContextDecision::Apply(None)); + }; + match rc.tool_group_id() { + // A non-admin that names no group is treated as a clear (→ default group). + None => Ok(RunContextDecision::Apply(None)), + Some(group) => { + if crate::db::roles::role_allows_group(registry_pool, role_id, group).await? { + let group = group.to_string(); + Ok(RunContextDecision::Apply(Some(RunContext::with_security_group(Some(group))))) + } else { + Ok(RunContextDecision::Forbidden(group.to_string())) + } + } + } +} + pub struct RunContextManager { db: Arc, approval: Arc, @@ -373,4 +420,61 @@ mod tests { std::fs::remove_dir_all(&wd).ok(); } + + #[tokio::test] + async fn validate_admin_passes_through_untouched() { + let path = unique_tmp().join("system.db"); + let pool = crate::db::init_system_pool(path.to_str().unwrap()).await.unwrap(); + let rc = RunContext { + security_group: Some("ops".into()), + allow_fs_writes: vec!["/etc".into()], + ..Default::default() + }; + match validate_run_context_for_role(&pool, "admin", Some(rc)).await.unwrap() { + RunContextDecision::Apply(Some(got)) => { + assert_eq!(got.tool_group_id(), Some("ops")); + assert_eq!(got.allow_fs_writes, vec!["/etc".to_string()]); + } + _ => panic!("admin must pass through unchanged"), + } + } + + #[tokio::test] + async fn validate_non_admin_gates_group_and_strips_fs() { + let path = unique_tmp().join("system.db"); + let pool = crate::db::init_system_pool(path.to_str().unwrap()).await.unwrap(); + crate::db::roles::insert(&pool, "member", "Member", "default", + Some(r#"{"permission_groups":["ops"]}"#)).await.unwrap(); + + // Allowed group: kept, but every other field is discarded (fs hardening). + let rc = RunContext { + security_group: Some("ops".into()), + allow_fs_writes: vec!["/etc".into()], + system_prompt: vec!["ignore me".into()], + ..Default::default() + }; + match validate_run_context_for_role(&pool, "member", Some(rc)).await.unwrap() { + RunContextDecision::Apply(Some(got)) => { + assert_eq!(got.tool_group_id(), Some("ops")); + assert!(got.allow_fs_writes.is_empty()); + assert!(got.system_prompt.is_empty()); + } + _ => panic!("an allowed group must apply, sanitized"), + } + + // A group outside the role's set is refused. + let rc = RunContext { security_group: Some("secret".into()), ..Default::default() }; + match validate_run_context_for_role(&pool, "member", Some(rc)).await.unwrap() { + RunContextDecision::Forbidden(g) => assert_eq!(g, "secret"), + _ => panic!("a group outside the set must be forbidden"), + } + + // Clearing is always allowed (falls back to the role default at build time). + match validate_run_context_for_role(&pool, "member", None).await.unwrap() { + RunContextDecision::Apply(None) => {} + _ => panic!("clear must be allowed"), + } + + std::fs::remove_dir_all(path.parent().unwrap()).ok(); + } } diff --git a/crates/skald-core/src/setup/mod.rs b/crates/skald-core/src/setup/mod.rs new file mode 100644 index 0000000..fcf5c65 --- /dev/null +++ b/crates/skald-core/src/setup/mod.rs @@ -0,0 +1,159 @@ +//! First-run instance initialization — the seam both setup shells share. +//! +//! `skald-setup` (the terminal wizard) and the web setup endpoint both need to do +//! the same thing exactly once: seed the instance's roles from a chosen **seed +//! profile** and create the first admin. Keeping that here — rather than duplicated +//! in each shell — is what stops the two paths from drifting apart. +//! +//! A [`SeedProfile`] is the neutral primitive (§0.1); the domain flavour ("Family", +//! "Office", …) lives only in the profile's seed data — labels and role presets, +//! never in the engine. One profile ships today; adding another is data, not code. + +use anyhow::{Result, anyhow}; +use sqlx::SqlitePool; + +use crate::db::{self, roles::ADMIN_ROLE_ID}; +use crate::users::UserManager; + +/// One role a profile seeds. `attrs` is the `roles.attrs` JSON (§0.1) — `ui_mode`, +/// allowed security-groups, and future role attributes. +pub struct RoleSeed { + pub id: &'static str, + pub label: &'static str, + pub permission_group: &'static str, + pub attrs: Option<&'static str>, +} + +/// A named preset of roles the admin picks at first-run. Neutral mechanism; the +/// domain lives in the data. +pub struct SeedProfile { + pub id: &'static str, + pub label: &'static str, + pub roles: Vec, +} + +/// The profiles offered by the setup picker. `admin` is seeded universally at +/// table-creation (an FK invariant, `db::roles::seed_admin`), so a profile only +/// adds its **domain** roles. Ship one now; `office` / `family-no-kids` are just +/// more entries here — no engine change. +pub fn seed_profiles() -> Vec { + vec![SeedProfile { + id: "family", + label: "Family", + roles: vec![ + RoleSeed { + id: "member", + label: "Member", + permission_group: "default", + attrs: Some(r#"{"ui_mode":"full"}"#), + }, + RoleSeed { + id: "children", + label: "Children", + permission_group: "default", + attrs: Some(r#"{"ui_mode":"simple"}"#), + }, + ], + }] +} + +/// Look up a profile by id. +pub fn seed_profile(id: &str) -> Option { + seed_profiles().into_iter().find(|p| p.id == id) +} + +/// Seed a profile's roles (+ their default self-service capabilities) into the +/// registry. Idempotent: an existing role id is left untouched, so a re-run never +/// clobbers an admin-edited role. Runs at first-run, after every registry table +/// exists — so `role_capabilities` is present (no ordering hazard). +pub async fn apply_seed_profile(pool: &SqlitePool, profile_id: &str) -> Result<()> { + let profile = + seed_profile(profile_id).ok_or_else(|| anyhow!("unknown seed profile: {profile_id}"))?; + for role in &profile.roles { + if db::roles::get(pool, role.id).await?.is_some() { + continue; // already present — leave it as the admin left it + } + db::roles::insert(pool, role.id, role.label, role.permission_group, role.attrs).await?; + // The standard self-service capabilities, exactly as `roles::create` grants + // them through the API (§14). + db::role_capabilities::seed_defaults(pool, role.id).await?; + } + Ok(()) +} + +/// Everything a shell needs to know about the first admin. +pub struct FirstAdmin<'a> { + pub username: &'a str, + pub display_name: Option<&'a str>, + pub password: Option<&'a str>, + pub encrypted: bool, + /// Interface language → the instance default (`ui_locale`). `None` leaves the + /// registry default (English) in place. + pub locale: Option<&'a str>, +} + +/// First-run initialization, shared by both setup shells: apply the chosen seed +/// profile, create the admin, set the instance default locale. Returns the new +/// admin's user id. +/// +/// The default-locale write goes straight to `db::config` (no system bus): at +/// first-run nothing is listening, so both shells converge on the same path. +pub async fn initialize_instance( + users: &UserManager, + pool: &SqlitePool, + profile_id: &str, + admin: FirstAdmin<'_>, +) -> Result { + apply_seed_profile(pool, profile_id).await?; + + let id = users + .register_user( + admin.username, + admin.display_name, + ADMIN_ROLE_ID, + admin.password, + admin.encrypted, + ) + .await?; + + if let Some(locale) = admin.locale { + crate::i18n::set_default_locale(pool, locale).await?; + } + + Ok(id) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::roles::UiMode; + + fn tmp_db(tag: &str) -> String { + let dir = std::env::temp_dir().join(format!("skald-setup-{tag}-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join("system.db").to_str().unwrap().to_string() + } + + #[tokio::test] + async fn family_profile_seeds_roles_and_caps_idempotently() { + let pool = crate::db::init_system_pool(&tmp_db("family")).await.unwrap(); + apply_seed_profile(&pool, "family").await.unwrap(); + apply_seed_profile(&pool, "family").await.unwrap(); // idempotent + + let member = db::roles::get(&pool, "member").await.unwrap().unwrap(); + assert_eq!(member.attrs_parsed().ui_mode, UiMode::Full); + let children = db::roles::get(&pool, "children").await.unwrap().unwrap(); + assert_eq!(children.attrs_parsed().ui_mode, UiMode::Simple); + + // The standard self-service capabilities were granted to a seeded role. + assert!(db::role_capabilities::has( + &pool, "member", db::role_capabilities::REGISTER_REMOTE, + ).await.unwrap()); + } + + #[tokio::test] + async fn unknown_profile_is_an_error() { + let pool = crate::db::init_system_pool(&tmp_db("unknown")).await.unwrap(); + assert!(apply_seed_profile(&pool, "does-not-exist").await.is_err()); + } +} diff --git a/crates/skald-setup/src/main.rs b/crates/skald-setup/src/main.rs index 6c0bb2c..e3c93bc 100644 --- a/crates/skald-setup/src/main.rs +++ b/crates/skald-setup/src/main.rs @@ -25,13 +25,9 @@ use std::io::{self, IsTerminal, Write}; use anyhow::{Context, Result}; use skald_core::db::{self, SYSTEM_DB_PATH}; +use skald_core::setup::{self, FirstAdmin}; use skald_core::users::UserManager; -/// The role id given to the first user. There is no `roles` table yet, and -/// `users.role_id` has no foreign key, so this is a plain string for now — the -/// seeded `admin` preset (blueprint §12) will adopt it later without a migration. -const ADMIN_ROLE: &str = "admin"; - /// SQLCipher's per-user privacy is only as strong as the password's entropy /// times the KDF cost (§5.1). The KDF is fixed; this is the floor we put under /// the entropy. Not a substitute for a real strength meter — a deliberate, @@ -139,6 +135,7 @@ async fn step_first_user(users: &UserManager, pool: &sqlx::SqlitePool, has_admin println!("\nWelcome to Skald. Let's create the first user (the admin).\n"); + let profile = prompt_profile()?; let username = prompt_username()?; let display_name = prompt_line("Display name (optional): ")?; let display_name = display_name.trim(); @@ -148,16 +145,23 @@ async fn step_first_user(users: &UserManager, pool: &sqlx::SqlitePool, has_admin let encrypt = prompt_encrypt()?; let password = prompt_new_password()?; - let id = users - .register_user(&username, display_name, ADMIN_ROLE, Some(&password), encrypt) - .await - .context("creating the admin user")?; - - // The first-run language choice is instance-wide: the registry config - // default every user follows until they override it on their profile. - skald_core::i18n::set_default_locale(pool, &locale) - .await - .context("saving the default language")?; + // The shared seam both setup shells call: seed the chosen profile's roles, + // create the admin, set the instance default language — one implementation, so + // the terminal and web wizards can never drift apart. + let id = setup::initialize_instance( + users, + pool, + &profile, + FirstAdmin { + username: &username, + display_name, + password: Some(&password), + encrypted: encrypt, + locale: Some(&locale), + }, + ) + .await + .context("initializing the instance")?; println!("\n✓ Admin user '{username}' created (id {id})."); if encrypt { @@ -169,6 +173,34 @@ async fn step_first_user(users: &UserManager, pool: &sqlx::SqlitePool, has_admin // ── Prompts ──────────────────────────────────────────────────────────────── +/// Which seed profile to provision. The profile decides which domain roles the +/// instance starts with (`family` → member + children). With a single profile +/// there is nothing to choose, so it is selected silently. +fn prompt_profile() -> Result { + let profiles = setup::seed_profiles(); + if profiles.len() <= 1 { + return Ok(profiles + .into_iter() + .next() + .map(|p| p.id.to_string()) + .unwrap_or_else(|| "family".to_string())); + } + println!("What kind of instance is this?"); + for (i, p) in profiles.iter().enumerate() { + println!(" {}) {}", i + 1, p.label); + } + loop { + let line = prompt_line("Choose [1]: ")?; + let line = line.trim(); + let choice = if line.is_empty() { 1 } else { line.parse::().unwrap_or(0) }; + if (1..=profiles.len()).contains(&choice) { + println!(); + return Ok(profiles[choice - 1].id.to_string()); + } + println!(" Please enter a number between 1 and {}.", profiles.len()); + } +} + fn prompt_username() -> Result { loop { let name = prompt_line("Username: ")?; diff --git a/install-nightly.sh b/install-nightly.sh new file mode 100755 index 0000000..4ae8562 --- /dev/null +++ b/install-nightly.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env sh +# install-nightly.sh — install the latest nightly build of Skald Circle +# +# Usage: +# curl -fsSL https://builds.skaldagent.net/install-nightly.sh | bash +# +# Supports Linux (systemd) and macOS ARM64 (launchd). +# Default install dir: ~/.local/share/skald-circle (override with SKALD_DIR). +# +# Inspired by: https://hermes-agent.nousresearch.com/install.sh + +set -eu + +# ── User overrides ──────────────────────────────────────────────────────────── +INSTALL_DIR="${SKALD_DIR:-$HOME/.local/share/skald-circle}" + +# ── Colours (if terminal) ───────────────────────────────────────────────────── +if [ -t 1 ]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + BOLD='\033[1m' + NC='\033[0m' +else + RED=''; GREEN=''; YELLOW=''; BOLD=''; NC='' +fi + +info() { printf "${GREEN}%s${NC}\n" "$*"; } +warn() { printf "${YELLOW}⚠ %s${NC}\n" "$*"; } +err() { printf "${RED}✖ %s${NC}\n" "$*"; } +header(){ printf "\n${BOLD}%s${NC}\n" "$*"; } + +# ── Platform detection ──────────────────────────────────────────────────────── +OS="$(uname -s)" +ARCH="$(uname -m)" + +case "$OS" in + Linux) OS="linux" ;; + Darwin) OS="darwin" ;; + *) err "Unsupported OS: $OS"; exit 1 ;; +esac + +case "$ARCH" in + x86_64) + ARCH="amd64" + if [ "$OS" = "darwin" ]; then + err "Intel Macs are not supported. Apple Silicon (M1+) only." + exit 1 + fi + ;; + aarch64|arm64) + ARCH="arm64" + ;; + *) err "Unsupported architecture: $ARCH"; exit 1 ;; +esac + +# ── Dependency checks ───────────────────────────────────────────────────────── +command -v curl >/dev/null 2>&1 || { err "curl is required but not installed."; exit 1; } + +if [ "$OS" = "linux" ]; then + command -v systemctl >/dev/null 2>&1 || { warn "systemd not found — service will not be installed automatically."; NOSYSTEMD=1; } +elif [ "$OS" = "darwin" ]; then + command -v launchctl >/dev/null 2>&1 || { err "launchctl not found."; exit 1; } +fi + +# ── Download & extract ──────────────────────────────────────────────────────── +BASE_URL="https://builds.skaldagent.net" +TARBALL_URL="${BASE_URL}/nightly/skald-circle-nightly-${OS}-${ARCH}.tar.gz" + +header "📦 Skald Circle — Nightly Installer" +echo "" +echo " Platform : ${OS}/${ARCH}" +echo " Install dir : ${INSTALL_DIR}" +echo " Download : ${TARBALL_URL}" +echo "" + +info "↓ Downloading Skald Circle nightly …" +mkdir -p "$INSTALL_DIR" +curl -fsSL "$TARBALL_URL" | tar xz -C "$INSTALL_DIR" --strip-components=1 + +if [ ! -x "$INSTALL_DIR/bin/skald" ]; then + err "Download or extraction failed — skald binary not found." + exit 1 +fi + +info "✔ Extracted to ${INSTALL_DIR}" + +# ── Python venv (best-effort) ───────────────────────────────────────────────── +info "🔧 Setting up Python virtual environment …" +"$INSTALL_DIR/run.sh" >/dev/null 2>&1 || true + +# ── First-run setup (interactive) ───────────────────────────────────────────── +if [ -t 0 ] && [ -x "$INSTALL_DIR/bin/skald-setup" ]; then + header "⚙️ First-time setup" + echo " You will be asked to configure your LLM provider and create an admin user." + echo "" + "$INSTALL_DIR/bin/skald-setup" + echo "" +fi + +# ── Install daemon ──────────────────────────────────────────────────────────── +if [ "$OS" = "linux" ] && [ -z "${NOSYSTEMD:-}" ]; then + header "⚡ Installing systemd user service …" + + mkdir -p "$HOME/.config/systemd/user" + + cat > "$HOME/.config/systemd/user/skald-circle.service" <<- SERVICE +[Unit] +Description=Skald Circle (nightly) +Documentation=https://skaldagent.net +After=network.target + +[Service] +Type=simple +ExecStart=${INSTALL_DIR}/run.sh +WorkingDirectory=${INSTALL_DIR} +Restart=on-failure +RestartSec=5 +Environment=SKALD_BIN=${INSTALL_DIR}/bin/skald +Environment=SKALD_SETUP_BIN=${INSTALL_DIR}/bin/skald-setup + +[Install] +WantedBy=default.target +SERVICE + + systemctl --user daemon-reload + systemctl --user enable --now skald-circle.service + + info "✔ Service installed and started" + echo "" + echo " Status: systemctl --user status skald-circle" + echo " Logs: journalctl --user -u skald-circle -f" + +elif [ "$OS" = "darwin" ]; then + header "⚡ Installing launchd agent …" + + mkdir -p "$HOME/Library/LaunchAgents" "$INSTALL_DIR/logs" + + PLIST="$HOME/Library/LaunchAgents/com.skald.circle.plist" + + cat > "$PLIST" <<- PLIST + + + + + Label + com.skald.circle + + ProgramArguments + + ${INSTALL_DIR}/run.sh + + + WorkingDirectory + ${INSTALL_DIR} + + RunAtLoad + + KeepAlive + + + StandardOutPath + ${INSTALL_DIR}/logs/stdout.log + StandardErrorPath + ${INSTALL_DIR}/logs/stderr.log + + EnvironmentVariables + + SKALD_BIN + ${INSTALL_DIR}/bin/skald + SKALD_SETUP_BIN + ${INSTALL_DIR}/bin/skald-setup + + + +PLIST + + launchctl load "$PLIST" + + info "✔ Agent installed and started" + echo "" + echo " Status: launchctl list com.skald.circle" + echo " Logs: tail -f ${INSTALL_DIR}/logs/stdout.log" + +elif [ -n "${NOSYSTEMD:-}" ]; then + warn "systemd not available — start manually: ${INSTALL_DIR}/run.sh" +fi + +echo "" +info "✅ Skald Circle (nightly) installed successfully!" +echo "" +echo " ${INSTALL_DIR}/run.sh" +echo " ${INSTALL_DIR}/bin/skald" +echo " ${INSTALL_DIR}/bin/skald-setup" diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..7a9c4cc --- /dev/null +++ b/install.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env sh +# install.sh — install the latest release of Skald Circle +# +# Usage: +# curl -fsSL https://builds.skaldagent.net/install.sh | bash +# +# Supports Linux (systemd) and macOS ARM64 (launchd). +# Default install dir: ~/.local/share/skald-circle (override with SKALD_DIR). +# +# Inspired by: https://hermes-agent.nousresearch.com/install.sh + +set -eu + +# ── User overrides ──────────────────────────────────────────────────────────── +INSTALL_DIR="${SKALD_DIR:-$HOME/.local/share/skald-circle}" + +# ── Colours (if terminal) ───────────────────────────────────────────────────── +if [ -t 1 ]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + BOLD='\033[1m' + NC='\033[0m' +else + RED=''; GREEN=''; YELLOW=''; BOLD=''; NC='' +fi + +info() { printf "${GREEN}%s${NC}\n" "$*"; } +warn() { printf "${YELLOW}⚠ %s${NC}\n" "$*"; } +err() { printf "${RED}✖ %s${NC}\n" "$*"; } +header(){ printf "\n${BOLD}%s${NC}\n" "$*"; } + +# ── Platform detection ──────────────────────────────────────────────────────── +OS="$(uname -s)" +ARCH="$(uname -m)" + +case "$OS" in + Linux) OS="linux" ;; + Darwin) OS="darwin" ;; + *) err "Unsupported OS: $OS"; exit 1 ;; +esac + +case "$ARCH" in + x86_64) + ARCH="amd64" + if [ "$OS" = "darwin" ]; then + err "Intel Macs are not supported. Apple Silicon (M1+) only." + exit 1 + fi + ;; + aarch64|arm64) + ARCH="arm64" + ;; + *) err "Unsupported architecture: $ARCH"; exit 1 ;; +esac + +# ── Dependency checks ───────────────────────────────────────────────────────── +command -v curl >/dev/null 2>&1 || { err "curl is required but not installed."; exit 1; } + +if [ "$OS" = "linux" ]; then + command -v systemctl >/dev/null 2>&1 || { warn "systemd not found — service will not be installed automatically."; NOSYSTEMD=1; } +elif [ "$OS" = "darwin" ]; then + command -v launchctl >/dev/null 2>&1 || { err "launchctl not found."; exit 1; } +fi + +# ── Fetch latest version ────────────────────────────────────────────────────── +BASE_URL="https://builds.skaldagent.net" +LATEST_URL="${BASE_URL}/releases/LATEST" + +header "📦 Skald Circle — Installer" +echo "" + +info "🔍 Looking up latest release …" +VERSION="$(curl -fsSL "$LATEST_URL" | head -1 | tr -d '[:space:]')" + +if [ -z "$VERSION" ]; then + err "Could not determine latest release version." + err "Check ${LATEST_URL} or try install-nightly.sh for the latest build." + exit 1 +fi + +TARBALL_URL="${BASE_URL}/releases/${VERSION}/skald-circle-${VERSION}-${OS}-${ARCH}.tar.gz" + +echo "" +echo " Version : ${VERSION}" +echo " Platform : ${OS}/${ARCH}" +echo " Install dir : ${INSTALL_DIR}" +echo " Download : ${TARBALL_URL}" +echo "" + +# ── Download & extract ──────────────────────────────────────────────────────── +info "↓ Downloading Skald Circle ${VERSION} …" +mkdir -p "$INSTALL_DIR" +curl -fsSL "$TARBALL_URL" | tar xz -C "$INSTALL_DIR" --strip-components=1 + +if [ ! -x "$INSTALL_DIR/bin/skald" ]; then + err "Download or extraction failed — skald binary not found." + exit 1 +fi + +info "✔ Extracted to ${INSTALL_DIR}" + +# ── Python venv (best-effort) ───────────────────────────────────────────────── +info "🔧 Setting up Python virtual environment …" +"$INSTALL_DIR/run.sh" >/dev/null 2>&1 || true + +# ── First-run setup (interactive) ───────────────────────────────────────────── +if [ -t 0 ] && [ -x "$INSTALL_DIR/bin/skald-setup" ]; then + header "⚙️ First-time setup" + echo " You will be asked to configure your LLM provider and create an admin user." + echo "" + "$INSTALL_DIR/bin/skald-setup" + echo "" +fi + +# ── Install daemon ──────────────────────────────────────────────────────────── +if [ "$OS" = "linux" ] && [ -z "${NOSYSTEMD:-}" ]; then + header "⚡ Installing systemd user service …" + + mkdir -p "$HOME/.config/systemd/user" + + cat > "$HOME/.config/systemd/user/skald-circle.service" <<- SERVICE +[Unit] +Description=Skald Circle (release ${VERSION}) +Documentation=https://skaldagent.net +After=network.target + +[Service] +Type=simple +ExecStart=${INSTALL_DIR}/run.sh +WorkingDirectory=${INSTALL_DIR} +Restart=on-failure +RestartSec=5 +Environment=SKALD_BIN=${INSTALL_DIR}/bin/skald +Environment=SKALD_SETUP_BIN=${INSTALL_DIR}/bin/skald-setup + +[Install] +WantedBy=default.target +SERVICE + + systemctl --user daemon-reload + systemctl --user enable --now skald-circle.service + + info "✔ Service installed and started" + echo "" + echo " Status: systemctl --user status skald-circle" + echo " Logs: journalctl --user -u skald-circle -f" + +elif [ "$OS" = "darwin" ]; then + header "⚡ Installing launchd agent …" + + mkdir -p "$HOME/Library/LaunchAgents" "$INSTALL_DIR/logs" + + PLIST="$HOME/Library/LaunchAgents/com.skald.circle.plist" + + cat > "$PLIST" <<- PLIST + + + + + Label + com.skald.circle + + ProgramArguments + + ${INSTALL_DIR}/run.sh + + + WorkingDirectory + ${INSTALL_DIR} + + RunAtLoad + + KeepAlive + + + StandardOutPath + ${INSTALL_DIR}/logs/stdout.log + StandardErrorPath + ${INSTALL_DIR}/logs/stderr.log + + EnvironmentVariables + + SKALD_BIN + ${INSTALL_DIR}/bin/skald + SKALD_SETUP_BIN + ${INSTALL_DIR}/bin/skald-setup + + + +PLIST + + launchctl load "$PLIST" + + info "✔ Agent installed and started" + echo "" + echo " Status: launchctl list com.skald.circle" + echo " Logs: tail -f ${INSTALL_DIR}/logs/stdout.log" + +elif [ -n "${NOSYSTEMD:-}" ]; then + warn "systemd not available — start manually: ${INSTALL_DIR}/run.sh" +fi + +echo "" +info "✅ Skald Circle ${VERSION} installed successfully!" +echo "" +echo " ${INSTALL_DIR}/run.sh" +echo " ${INSTALL_DIR}/bin/skald" +echo " ${INSTALL_DIR}/bin/skald-setup" diff --git a/src/frontend/api/auth.rs b/src/frontend/api/auth.rs index c3c0543..64ca7bb 100644 --- a/src/frontend/api/auth.rs +++ b/src/frontend/api/auth.rs @@ -109,22 +109,21 @@ pub async fn me( .into_response()) } -/// Reads `roles.attrs.ui_mode` for the given role. Any error or missing key -/// resolves to "full" — the simplified UI is strictly opt-in. +/// Reads `roles.attrs.ui_mode` for the given role via the typed [`RoleAttrs`] +/// (the single attrs parse point). Any error or missing key resolves to "full" — +/// the simplified UI is strictly opt-in, and `admin` is always "full". async fn resolve_ui_mode(skald: &Skald, role_id: &str) -> String { - if role_id == skald_core::db::roles::ADMIN_ROLE_ID { + use skald_core::db::roles; + if role_id == roles::ADMIN_ROLE_ID { return "full".into(); } - let attrs = skald_core::db::roles::get(skald.db(), role_id) + let ui_mode = roles::get(skald.db(), role_id) .await .ok() .flatten() - .and_then(|r| r.attrs); - attrs - .and_then(|a| serde_json::from_str::(&a).ok()) - .and_then(|v| v.get("ui_mode")?.as_str().map(str::to_owned)) - .filter(|m| m == "simple" || m == "full") - .unwrap_or_else(|| "full".into()) + .map(|r| r.attrs_parsed().ui_mode) + .unwrap_or_default(); + ui_mode.as_str().into() } // ── POST /api/auth/logout ──────────────────────────────────────────────────── diff --git a/src/frontend/api/guard.rs b/src/frontend/api/guard.rs index ebf5dc8..d0a2082 100644 --- a/src/frontend/api/guard.rs +++ b/src/frontend/api/guard.rs @@ -47,7 +47,12 @@ fn is_public(path: &str) -> bool { let p = path.strip_prefix("/api").unwrap_or(path); matches!( p, - "/auth/login" | "/auth/logout" | "/auth/me" | "/setup/status" | "/setup/user" + "/auth/login" + | "/auth/logout" + | "/auth/me" + | "/setup/status" + | "/setup/user" + | "/setup/profiles" ) } diff --git a/src/frontend/api/mod.rs b/src/frontend/api/mod.rs index 0150c20..31cdac1 100644 --- a/src/frontend/api/mod.rs +++ b/src/frontend/api/mod.rs @@ -54,6 +54,7 @@ pub fn router() -> Router> { .route("/sessions", get(sessions::list_sessions).post(sessions::create)) // First-run setup .route("/setup/status", get(setup::status)) + .route("/setup/profiles", get(setup::profiles)) .route("/setup/user", post(setup::create_user)) // Auth .route("/auth/login", post(auth::login)) @@ -129,6 +130,8 @@ pub fn router() -> Router> { .route("/tool-permission-groups", get(run_context::list_groups).post(run_context::create_group)) .route("/tool-permission-groups/{id}", put(run_context::update_group).delete(run_context::delete_group)) .route("/tool-permission-groups/{id}/duplicate", post(run_context::duplicate_group)) + // The caller's own selectable security-groups (for the chat picker) + .route("/my/security-groups", get(run_context::my_security_groups)) // Session tool_group assignment (runtime) .route("/sessions/{session_id}/run-context", put(run_context::set_session_run_context)) // MCP / Connectors (blueprint §14/§15) diff --git a/src/frontend/api/roles.rs b/src/frontend/api/roles.rs index 5e8abbf..a9f7561 100644 --- a/src/frontend/api/roles.rs +++ b/src/frontend/api/roles.rs @@ -32,6 +32,9 @@ pub async fn create( if body.label.trim().is_empty() { return Err(ApiError::bad_request("label must not be empty")); } + if body.permission_group.trim().is_empty() { + return Err(ApiError::bad_request("permission group must not be empty")); + } roles::insert(skald.db(), id, body.label.trim(), &body.permission_group, body.attrs.as_deref()) .await?; // Seed the standard self-service Connector capabilities (§14): a new role can @@ -57,6 +60,12 @@ pub async fn update( if id == ADMIN_ROLE_ID { return Err(ApiError::bad_request("the built-in admin role cannot be modified")); } + if body.label.trim().is_empty() { + return Err(ApiError::bad_request("label must not be empty")); + } + if body.permission_group.trim().is_empty() { + return Err(ApiError::bad_request("permission group must not be empty")); + } let ok = roles::update(skald.db(), &id, body.label.trim(), &body.permission_group, body.attrs.as_deref()) .await?; if !ok { diff --git a/src/frontend/api/run_context.rs b/src/frontend/api/run_context.rs index ede21fb..d6d8024 100644 --- a/src/frontend/api/run_context.rs +++ b/src/frontend/api/run_context.rs @@ -5,9 +5,10 @@ use axum::{ extract::{Path, State}, http::StatusCode, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use skald_core::db::roles; use skald_core::skald::Skald; use super::{ApiError, guard::AuthUser, require_context}; @@ -79,6 +80,54 @@ pub async fn duplicate_group( Ok(Json(json!({ "id": body.id }))) } +// ── GET /api/my/security-groups — the caller's selectable groups ────────────── + +#[derive(Serialize)] +pub struct MySecurityGroup { + pub id: String, + pub name: String, + pub is_default: bool, +} + +/// The security-groups the calling user may pick in the chat picker: its role's +/// effective set joined with the group names (`admin` → every group). The composer +/// renders this like the model list; the server still enforces the set on write. +pub async fn my_security_groups( + State(skald): State>, + Extension(auth): Extension, +) -> Result>, ApiError> { + let user = skald + .users() + .get(&auth.user_id) + .await? + .ok_or_else(|| ApiError::not_found("user not found"))?; + + let all = skald.run_context_manager().list_groups().await?; + + let (allowed, default_id): (Vec, String) = if user.role_id == roles::ADMIN_ROLE_ID { + (all.iter().map(|g| g.id.clone()).collect(), "default".to_string()) + } else { + match roles::get(skald.db(), &user.role_id).await? { + Some(role) => (role.effective_groups(), role.permission_group.clone()), + None => (vec!["default".to_string()], "default".to_string()), + } + }; + + // Keep only ids that still exist as groups; carry the display name from there. + let out = allowed + .into_iter() + .filter_map(|id| { + all.iter().find(|g| g.id == id).map(|g| MySecurityGroup { + is_default: g.id == default_id, + id: g.id.clone(), + name: g.name.clone(), + }) + }) + .collect(); + + Ok(Json(out)) +} + // ── Session run_context assignment ──────────────────────────────────────────── #[derive(Deserialize)] @@ -92,6 +141,30 @@ pub async fn set_session_run_context( Json(ctx): Json>, ) -> Result, ApiError> { let uctx = require_context(&skald, &auth.user_id).await?; + + // Gate the requested context by the caller's role: a non-admin may only pick a + // security-group in its role's set, and every other RunContext field is dropped + // (fs-escalation hardening). admin passes through. Same validator the WS path uses. + let user = skald + .users() + .get(&auth.user_id) + .await? + .ok_or_else(|| ApiError::not_found("user not found"))?; + let ctx = match skald_core::run_context::validate_run_context_for_role( + skald.db(), + &user.role_id, + ctx, + ) + .await? + { + skald_core::run_context::RunContextDecision::Apply(c) => c, + skald_core::run_context::RunContextDecision::Forbidden(g) => { + return Err(ApiError::forbidden(format!( + "security group '{g}' is not allowed for your role" + ))); + } + }; + // The session row (and its live handler) live in the caller's own pool, so the // persist + live update both target the user's context. Run-context *definitions* // (roles) remain instance-wide; only the per-session value is owner data. diff --git a/src/frontend/api/sessions.rs b/src/frontend/api/sessions.rs index 55bf783..f19e78c 100644 --- a/src/frontend/api/sessions.rs +++ b/src/frontend/api/sessions.rs @@ -10,8 +10,9 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sqlx::SqlitePool; -use skald_core::db::{chat_history, chat_llm_tools, chat_sessions, chat_sessions_stack, sources}; +use skald_core::db::{chat_history, chat_llm_tools, chat_sessions, chat_sessions_stack, roles, sources}; use skald_core::db::chat_sessions_stack::SessionStack; +use skald_core::run_context::RunContext; use std::sync::Arc; use skald_core::skald::{Skald, UserContext}; use skald_core::session::handler::ApprovalDecision; @@ -39,10 +40,32 @@ pub async fn create( // Resolve agent + RunContext from the source so project chats reset with the // coordinator agent (not the default `main`), then provision a fresh session. let (agent, rc) = super::projects::provisioning_for_source(&ctx.pool, &q.source).await?; + // A non-project chat inherits the caller role's default security-group, so a + // restricted role starts scoped instead of on the catch-all `default` group. + // Project chats already carry their own run-context and are left untouched. + let rc = match rc { + Some(rc) => Some(rc), + None => role_default_run_context(&skald, &auth.user_id).await?, + }; ctx.chat_hub.provision_session(&q.source, &agent, rc.as_ref(), true).await?; Ok(Json(json!({}))) } +/// The default security-group a new session gets from the owner's role, or `None` +/// when the role points at the catch-all `default` group (nothing to pin). +async fn role_default_run_context( + skald: &Skald, + user_id: &str, +) -> Result, ApiError> { + let Some(user) = skald.users().get(user_id).await? else { return Ok(None) }; + let Some(role) = roles::get(skald.db(), &user.role_id).await? else { return Ok(None) }; + let group = role.permission_group; + if group.is_empty() || group == "default" { + return Ok(None); + } + Ok(Some(RunContext::with_security_group(Some(group)))) +} + // ── GET /api/web/messages ───────────────────────────────────────────────────── pub async fn web_messages( diff --git a/src/frontend/api/setup.rs b/src/frontend/api/setup.rs index 0f6a0bb..49c641a 100644 --- a/src/frontend/api/setup.rs +++ b/src/frontend/api/setup.rs @@ -22,6 +22,24 @@ pub async fn status(State(skald): State>) -> Result Ok(Json(SetupStatus { needs_setup: count == 0 })) } +// ── GET /api/setup/profiles — seed profiles offered by the picker ──────────── + +#[derive(Serialize)] +pub struct SeedProfileInfo { + pub id: String, + pub label: String, +} + +/// The seed profiles the first-run picker offers (§0.1: the neutral mechanism, +/// domain flavour in the data). Pre-auth, so it is on the setup allowlist. +pub async fn profiles() -> Json> { + let list = skald_core::setup::seed_profiles() + .into_iter() + .map(|p| SeedProfileInfo { id: p.id.to_string(), label: p.label.to_string() }) + .collect(); + Json(list) +} + // ── POST /api/setup/user — create the first (admin) user ──────────────────── #[derive(Deserialize)] @@ -33,6 +51,9 @@ pub struct CreateUserBody { /// Chosen interface language — becomes the instance default (`ui_locale`). #[serde(default)] pub locale: Option, + /// Chosen seed profile id. Defaults to the first shipped profile. + #[serde(default)] + pub profile: Option, } #[derive(Serialize)] @@ -63,17 +84,31 @@ pub async fn create_user( return Err(ApiError::bad_request("unsupported locale")); } } - - let id = skald - .users() - .register_user(username, None, "admin", Some(&body.password), body.encrypted) - .await?; - - // The first-run language choice is instance-wide: it lands in the registry - // config as the default every user follows until they override it. - if let Some(l) = locale { - skald.config().set(skald_core::i18n::DEFAULT_LOCALE_KEY, l).await?; + let profile = body + .profile + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("family"); + if skald_core::setup::seed_profile(profile).is_none() { + return Err(ApiError::bad_request("unknown seed profile")); } + // The shared first-run seam: seed the profile's roles, create the admin, set + // the default locale — the same path skald-setup takes, so the two never drift. + let id = skald_core::setup::initialize_instance( + skald.users(), + skald.db(), + profile, + skald_core::setup::FirstAdmin { + username, + display_name: None, + password: Some(&body.password), + encrypted: body.encrypted, + locale, + }, + ) + .await?; + Ok(Json(CreateUserResult { user_id: id })) } diff --git a/src/frontend/api/ws.rs b/src/frontend/api/ws.rs index 1fbf5b6..858c99a 100644 --- a/src/frontend/api/ws.rs +++ b/src/frontend/api/ws.rs @@ -116,6 +116,23 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc, source: String, running: session_handler.is_processing(), })).await; + // Tell this (possibly reloaded) client the session's current security-group so + // the chat picker starts in sync. The twin of the model pill — but the group is + // per-session persisted, not a per-source RAM pin, so it must be sent on connect. + let _ = socket.send(to_msg(&ServerEvent::SecurityGroupSelected { + group: current_session_group(&ctx.pool, &source).await, + })).await; + + // Keepalive: a long, silent turn (e.g. a slow `execute_cmd` producing no + // events for a minute) sends nothing over the socket, so an idle proxy or the + // browser can drop it. A dropped socket loses any event broadcast during the + // ~2s reconnect gap — the bus is a `broadcast` with no replay — which is what + // left an approved tool card stuck on "running" until a manual reload. A + // periodic Ping keeps the connection warm. 25s beats common ~60s idle timeouts. + let mut keepalive = tokio::time::interval(std::time::Duration::from_secs(25)); + keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + keepalive.tick().await; // consume the immediate first tick (don't ping on connect) + loop { tokio::select! { // ── Inbound: message from the browser ──────────────────────────── @@ -151,6 +168,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc, source: String, if handle_question_answer_msg(&text, &session_handler).await { continue; } if handle_data_msg(&text, &skald) { continue; } if handle_select_client_msg(&text, &source, &chat_hub).await { continue; } + if handle_select_security_group_msg(&text, &source, &user_id, &skald, &ctx, &session_handler).await { continue; } // ── /sethome ────────────────────────────────────────────────── let client_msg: ClientMessage = match serde_json::from_str(&text) { @@ -414,6 +432,13 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc, source: String, Err(broadcast::error::RecvError::Closed) => return, } } + + // ── Keepalive tick: ping the client to keep the socket warm ─────── + _ = keepalive.tick() => { + if socket.send(Message::Ping(Default::default())).await.is_err() { + return; + } + } } } } @@ -496,6 +521,84 @@ async fn handle_select_client_msg( true } +/// Returns true if the message was a `select_security_group` control message +/// (caller should `continue`). The twin of [`handle_select_client_msg`] for the +/// session security-group: validate the requested group against the caller's role +/// (§0.1 — enforce server-side, never trust the client: a non-admin may only pick a +/// group in its role's set, and no other `RunContext` field is honoured), persist it +/// on the session row, update the live handler, and broadcast `SecurityGroupSelected` +/// so every open client stays in sync. +async fn handle_select_security_group_msg( + text: &str, + source: &str, + user_id: &str, + skald: &Arc, + ctx: &Arc, + session_handler: &Arc, +) -> bool { + use skald_core::run_context::{RunContext, RunContextDecision, validate_run_context_for_role}; + + let Ok(v) = serde_json::from_str::(text) else { return false }; + if v["type"].as_str() != Some("select_security_group") { return false } + + // `group` is a string (pick) or null/absent (clear → the role's default group). + let requested = v.get("group").and_then(|g| g.as_str()).map(str::to_string); + let incoming = requested.map(|g| RunContext::with_security_group(Some(g))); + + let Ok(Some(user)) = skald.users().get(user_id).await else { return true }; + let effective = match validate_run_context_for_role(skald.db(), &user.role_id, incoming).await { + Ok(RunContextDecision::Apply(rc)) => rc, + Ok(RunContextDecision::Forbidden(g)) => { + warn!(source, group = %g, "select_security_group: not in role's set — ignored"); + return true; + } + Err(e) => { + tracing::error!(error = %e, "select_security_group: validation failed"); + return true; + } + }; + + // Persist on the session row (owner pool) and update the live handler. + if let Ok(Some(sid)) = skald_core::db::sources::active_session_id(&ctx.pool, source).await { + let _ = skald_core::db::chat_sessions::set_run_context( + &ctx.pool, + sid, + effective.as_ref().map(|c| c.to_db()).as_deref(), + ) + .await; + } + session_handler.set_run_context(effective.clone()).await; + + // Broadcast the effective group id ("default" when cleared) to every client. + let group = effective + .as_ref() + .and_then(|rc| rc.tool_group_id().map(str::to_string)) + .unwrap_or_else(|| "default".to_string()); + ctx.chat_hub.emit(skald_core::events::GlobalEvent { + source: Some(source.to_string()), + session_id: None, + event: ServerEvent::SecurityGroupSelected { group }, + }); + true +} + +/// The active session's current security-group for `source`, or `"default"` when +/// no session or no run-context is set. Used to seed a freshly-connected client. +async fn current_session_group(pool: &sqlx::SqlitePool, source: &str) -> String { + use skald_core::run_context::RunContext; + let Ok(Some(sid)) = skald_core::db::sources::active_session_id(pool, source).await else { + return "default".to_string(); + }; + let group = skald_core::db::chat_sessions::find_by_id(pool, sid) + .await + .ok() + .flatten() + .and_then(|s| s.run_context) + .and_then(|s| RunContext::from_db(&s)) + .and_then(|rc| rc.tool_group_id().map(str::to_string)); + group.unwrap_or_else(|| "default".to_string()) +} + /// Returns true if the message was an inbound data push (caller should `continue`). /// Dispatches `{"type":"data","stream":"...","payload":{...}}` to the appropriate manager. fn handle_data_msg(text: &str, skald: &Arc) -> bool { diff --git a/uninstall.sh b/uninstall.sh new file mode 100644 index 0000000..87c3b15 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env sh +# uninstall.sh — remove Skald Circle and all its data +# +# Usage: +# ./uninstall.sh +# +# Stops the daemon (systemd on Linux, launchd on macOS), removes the +# service/agent file, then deletes the entire installation directory +# (config, database, everything). +# +# Set SKALD_DIR before running if you installed to a custom location: +# SKALD_DIR=/opt/skald-circle ./uninstall.sh + +set -eu + +# ── Colours (if terminal) ───────────────────────────────────────────────────── +if [ -t 1 ]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + BOLD='\033[1m' + NC='\033[0m' +else + RED=''; GREEN=''; YELLOW=''; BOLD=''; NC='' +fi + +info() { printf "${GREEN}%s${NC}\n" "$*"; } +warn() { printf "${YELLOW}⚠ %s${NC}\n" "$*"; } +err() { printf "${RED}✖ %s${NC}\n" "$*"; } + +# ── Determine install directory ─────────────────────────────────────────────── +# Default: the directory this script lives in (i.e. the bundle root). +if [ -n "${SKALD_DIR:-}" ]; then + INSTALL_DIR="$SKALD_DIR" +else + INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)" +fi + +# ── Detect OS ───────────────────────────────────────────────────────────────── +OS="$(uname -s)" + +echo "" +printf "\033[1m🗑️ Skald Circle — Uninstaller\033[0m\n" +echo "" +echo " This will permanently delete Skald Circle and all its data:" +echo " ${INSTALL_DIR}" +echo "" + +if [ -t 0 ]; then + printf "%s " "Are you sure? Type 'yes' to continue: " + read -r CONFIRM + [ "$CONFIRM" = "yes" ] || { echo "Aborted."; exit 0; } + echo "" +fi + +# ── Stop & remove daemon ────────────────────────────────────────────────────── +case "$OS" in + Linux) + SERVICE_NAME="skald-circle.service" + SERVICE_PATH="$HOME/.config/systemd/user/$SERVICE_NAME" + + if command -v systemctl >/dev/null 2>&1; then + if systemctl --user is-enabled "$SERVICE_NAME" >/dev/null 2>&1; then + info "⏹️ Stopping Skald Circle service …" + systemctl --user stop "$SERVICE_NAME" 2>/dev/null || true + systemctl --user disable "$SERVICE_NAME" 2>/dev/null || true + fi + + if [ -f "$SERVICE_PATH" ]; then + info "🗑️ Removing systemd service file …" + rm -f "$SERVICE_PATH" + systemctl --user daemon-reload 2>/dev/null || true + fi + else + warn "systemd not found — skipping service removal." + fi + ;; + + Darwin) + PLIST="$HOME/Library/LaunchAgents/com.skald.circle.plist" + + if [ -f "$PLIST" ]; then + info "⏹️ Stopping Skald Circle agent …" + launchctl unload "$PLIST" 2>/dev/null || true + info "🗑️ Removing launchd plist …" + rm -f "$PLIST" + else + warn "launchd plist not found at ${PLIST}" + fi + ;; + + *) + warn "Unknown OS: $OS — skipping daemon removal." + ;; +esac + +# ── Remove installation directory ───────────────────────────────────────────── +if [ -d "$INSTALL_DIR" ]; then + info "🗑️ Removing installation directory …" + rm -rf "$INSTALL_DIR" + info "✔ Removed ${INSTALL_DIR}" +else + warn "Installation directory not found: ${INSTALL_DIR}" +fi + +echo "" +info "✅ Skald Circle has been uninstalled." +echo " If you want to reinstall:" +echo " curl -fsSL https://builds.skaldagent.net/install.sh | bash" +echo " curl -fsSL https://builds.skaldagent.net/install-nightly.sh | bash" +echo "" diff --git a/web/components/copilot.js b/web/components/copilot.js index c554b23..95a8e50 100644 --- a/web/components/copilot.js +++ b/web/components/copilot.js @@ -25,6 +25,7 @@ export class AppCopilot extends I18nMixin(ChatSession) { _mode: { state: true }, _me: { state: true }, _modelOpen: { state: true }, + _groupOpen: { state: true }, _tabs: { state: true }, _activeSource: { state: true }, _cmdMenu: { state: true }, @@ -38,6 +39,7 @@ export class AppCopilot extends I18nMixin(ChatSession) { this._mode = 'dock'; this._me = null; this._modelOpen = false; + this._groupOpen = false; this._resizing = false; // Slash-command autocomplete: `_cmdMenu` is the filtered list currently shown // (null = hidden), `_cmdSel` the highlighted index, `_allCommands` the merged @@ -62,6 +64,7 @@ export class AppCopilot extends I18nMixin(ChatSession) { this._restoreState(); this._loadCommands(); this._loadMe(); + this._loadSecurityGroups(); // Same element, two layouts: the chat is the home page ('full') and docks // to the side on every other route — state is never lost, it only resizes. this._applyMode(this._pageFromHash() === 'home' ? 'full' : 'dock'); @@ -450,6 +453,29 @@ export class AppCopilot extends I18nMixin(ChatSession) { ` : nothing} + ${this._securityGroups.length > 1 ? html` +
+ ${this._groupOpen ? html` +
{ this._groupOpen = false; }}>
+
+ ${this._securityGroups.map(g => html` + + `)} +
+ ` : nothing} + +
+ ` : nothing}