Setup: utente admin via web, ruoli e run-context con security-group, onboarding install/uninstall script
Nightly Build / build (push) Failing after 6m12s
Nightly Build / build (push) Failing after 6m12s
This commit is contained in:
@@ -86,3 +86,8 @@ jobs:
|
|||||||
cp dist/*.tar.gz "$TARGET/"
|
cp dist/*.tar.gz "$TARGET/"
|
||||||
echo "[release] Deployed $VERSION:"
|
echo "[release] Deployed $VERSION:"
|
||||||
ls -lh "$TARGET/"
|
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 }}"
|
||||||
|
|||||||
@@ -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<dyn Plugin>` from `core-api` |
|
| `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<dyn Plugin>` 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 |
|
| `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 |
|
| `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:
|
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.
|
`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)
|
## 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.<id>.<key>`) 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<LocaleBundle>` (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<dyn I18nApi>`. 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.<id>.` namespace convention. The mechanism is general (any plugin, and eventually the core, registers the same way); only mobile-connector uses it so far.
|
**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.<id>.<key>`) 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<LocaleBundle>` (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<dyn I18nApi>`. 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.<id>.` 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 |
|
| File | Element | Notes |
|
||||||
| ---- | ------- | ----- |
|
| ---- | ------- | ----- |
|
||||||
|
|||||||
@@ -1,56 +1,115 @@
|
|||||||
# Skald Circle — SKALD
|
# 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 |
|
| Agent | Animal | Status |
|
||||||
|--------|---------|-------|
|
|-------|--------|--------|
|
||||||
| Main Assistant | 🦊 Volpe | ✅ |
|
| Main Assistant | 🦊 Fox | ✅ |
|
||||||
| Project Coordinator | 🦡 Tasso | ✅ |
|
| Project Coordinator | 🦡 Badger | ✅ |
|
||||||
| Researcher | 🐿️ Scoiattolo | ✅ |
|
| Researcher | 🐿️ Squirrel | ✅ |
|
||||||
| Generalist | 🦫 Castoro | ✅ |
|
| Generalist | 🦫 Beaver | ✅ |
|
||||||
| Code Explorer | 🕵️ Meerkat | ✅ |
|
| Code Explorer | 🕵️ Meerkat | ✅ |
|
||||||
| Software Architect | 🏗️ Airone | ✅ |
|
| Software Architect | 🏗️ Heron | ✅ |
|
||||||
| Software Engineer | 🔧 Orso | ✅ |
|
| Software Engineer | 🔧 Bear | ✅ |
|
||||||
| Spec Writer | 📝 Gufo | ✅ |
|
| Spec Writer | 📝 Owl | ✅ |
|
||||||
| Tech Lead | 👑 Cervo | ✅ |
|
| Tech Lead | 👑 Deer | ✅ |
|
||||||
| TIC | 👁️ Gatto | ✅ |
|
| TIC | 👁️ Cat | ✅ |
|
||||||
| Business Analyst | 💼 Gazza | ✅ |
|
| Business Analyst | 💼 Magpie | ✅ |
|
||||||
|
|
||||||
### Refactoring — completato ✅
|
### Refactoring — completed ✅
|
||||||
|
|
||||||
- Rimossa dipendenza da Tauri/desktop (`tauri.conf.json`, `src/desktop/`, `icons/`, `docs/desktop.md`, schemi gen/)
|
- Removed Tauri/desktop dependency (`tauri.conf.json`, `src/desktop/`, `icons/`, `docs/desktop.md`, gen schemas/)
|
||||||
- Rimosso `build.rs` (non più necessario)
|
- Removed `build.rs` (no longer needed)
|
||||||
- Nuovo sistema i18n (core-api + plugin-mobile-connector + web)
|
- New i18n system (core-api + plugin-mobile-connector + web)
|
||||||
- Refactoring sistema di configurazione
|
- Configuration system refactoring
|
||||||
|
|
||||||
### Auto-build CI/CD ✅
|
### 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 | ✅ |
|
| `ci/package.sh` | Creates distribution tarballs from compiled binaries | ✅ |
|
||||||
| `scripts/verify-version.sh` | Verifica che una release non sia già buildata | ✅ |
|
| `ci/verify-version.sh` | Verifies that a release hasn't been built yet | ✅ |
|
||||||
| `.gitea/workflows/nightly.yml` | Push su `main` → build amd64+arm64 → nightly/ | ✅ |
|
| `.gitea/workflows/nightly.yml` | Push to `main` → build amd64+arm64 → nightly/ | ✅ |
|
||||||
| `.gitea/workflows/release.yml` | PR check `verify-version` + merge → build → releases/v{ver}/ | ✅ |
|
| `.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` | ✅ |
|
| **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 | ✅ |
|
| **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)
|
- `scripts/` in `.gitignore` — CI scripts moved to `ci/` (tracked by git)
|
||||||
- Testare il workflow con una PR su `release`
|
- Build without `whisper-local` on Linux (`--no-default-features`)
|
||||||
- Creare `install.sh` per installazione one-liner
|
- `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.
|
||||||
|
|||||||
+46
-19
@@ -2,21 +2,22 @@
|
|||||||
# Package a Skald Circle build into a distributable tarball.
|
# Package a Skald Circle build into a distributable tarball.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./scripts/package.sh \
|
# ./ci/package.sh \
|
||||||
# --version v0.1.0 \
|
# --version v0.1.0 \
|
||||||
|
# --os linux \
|
||||||
# --arch amd64 \
|
# --arch amd64 \
|
||||||
# --target-dir target/release \
|
# --target-dir target/release \
|
||||||
# --output /tmp/dist
|
# --output /tmp/dist
|
||||||
#
|
#
|
||||||
# --version Version string, e.g. "v0.1.0" or "nightly"
|
# --version Version string, e.g. "v0.1.0" or "nightly"
|
||||||
|
# --os Target OS: "linux" or "darwin"
|
||||||
# --arch Architecture: "amd64" or "arm64"
|
# --arch Architecture: "amd64" or "arm64"
|
||||||
# --target-dir Path to cargo release output (target/release or
|
# --target-dir Path to cargo release output
|
||||||
# target/aarch64-unknown-linux-gnu/release)
|
|
||||||
# --output Directory where the .tar.gz will be written
|
# --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/,
|
# 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
|
set -eu
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ cd "$(dirname "$0")/.."
|
|||||||
|
|
||||||
# ── Parse args ────────────────────────────────────────────────────────────────
|
# ── Parse args ────────────────────────────────────────────────────────────────
|
||||||
VERSION=""
|
VERSION=""
|
||||||
|
OS=""
|
||||||
ARCH=""
|
ARCH=""
|
||||||
TARGET_DIR=""
|
TARGET_DIR=""
|
||||||
OUTPUT=""
|
OUTPUT=""
|
||||||
@@ -31,6 +33,7 @@ OUTPUT=""
|
|||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--version) VERSION="$2"; shift 2 ;;
|
--version) VERSION="$2"; shift 2 ;;
|
||||||
|
--os) OS="$2"; shift 2 ;;
|
||||||
--arch) ARCH="$2"; shift 2 ;;
|
--arch) ARCH="$2"; shift 2 ;;
|
||||||
--target-dir) TARGET_DIR="$2"; shift 2 ;;
|
--target-dir) TARGET_DIR="$2"; shift 2 ;;
|
||||||
--output) OUTPUT="$2"; shift 2 ;;
|
--output) OUTPUT="$2"; shift 2 ;;
|
||||||
@@ -38,12 +41,17 @@ while [ $# -gt 0 ]; do
|
|||||||
esac
|
esac
|
||||||
done
|
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
|
echo "[package.sh] Missing required argument. See usage." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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}"
|
STAGING="$(mktemp -d)/${PACKAGE_NAME}"
|
||||||
mkdir -p "$STAGING/bin"
|
mkdir -p "$STAGING/bin"
|
||||||
|
|
||||||
@@ -61,15 +69,23 @@ if [ ! -f "$TARGET_DIR/skald-setup" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Copy binaries (stripped) ──────────────────────────────────────────────────
|
# ── Copy binaries (stripped, best-effort on darwin) ───────────────────────────
|
||||||
if [ "$ARCH" = "arm64" ]; then
|
|
||||||
STRIP="aarch64-linux-gnu-strip"
|
|
||||||
else
|
|
||||||
STRIP="strip"
|
|
||||||
fi
|
|
||||||
cp "$TARGET_DIR/skald" "$STAGING/bin/skald"
|
cp "$TARGET_DIR/skald" "$STAGING/bin/skald"
|
||||||
cp "$TARGET_DIR/skald-setup" "$STAGING/bin/skald-setup"
|
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"
|
chmod 755 "$STAGING/bin/skald" "$STAGING/bin/skald-setup"
|
||||||
|
|
||||||
# ── Copy runtime assets ───────────────────────────────────────────────────────
|
# ── Copy runtime assets ───────────────────────────────────────────────────────
|
||||||
@@ -79,7 +95,8 @@ cp -r skills "$STAGING/skills"
|
|||||||
cp default.config.yaml "$STAGING/default.config.yaml"
|
cp default.config.yaml "$STAGING/default.config.yaml"
|
||||||
cp requirements.txt "$STAGING/requirements.txt"
|
cp requirements.txt "$STAGING/requirements.txt"
|
||||||
cp run.sh "$STAGING/run.sh"
|
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 ────────────────────────────────────────────────────────────
|
# ── Create tarball ────────────────────────────────────────────────────────────
|
||||||
mkdir -p "$OUTPUT"
|
mkdir -p "$OUTPUT"
|
||||||
@@ -91,7 +108,17 @@ cd - > /dev/null
|
|||||||
|
|
||||||
rm -rf "$(dirname "$STAGING")"
|
rm -rf "$(dirname "$STAGING")"
|
||||||
|
|
||||||
SHA256="$(sha256sum "$TARBALL" | cut -d' ' -f1)"
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
echo "[package.sh] ✅ Created $TARBALL"
|
SHA256="$(sha256sum "$TARBALL" | cut -d' ' -f1)"
|
||||||
echo "[package.sh] sha256: $SHA256"
|
echo "[package.sh] ✅ Created $TARBALL"
|
||||||
echo "[package.sh] size: $(du -h "$TARBALL" | cut -f1)"
|
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
|
||||||
|
|||||||
@@ -238,6 +238,13 @@ pub enum ServerEvent {
|
|||||||
ClientSelected {
|
ClientSelected {
|
||||||
client: String,
|
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 {
|
impl ServerEvent {
|
||||||
@@ -275,6 +282,7 @@ impl ServerEvent {
|
|||||||
Self::UserMessage { .. } => "user_message",
|
Self::UserMessage { .. } => "user_message",
|
||||||
Self::TurnRunning { .. } => "turn_running",
|
Self::TurnRunning { .. } => "turn_running",
|
||||||
Self::ClientSelected { .. } => "client_selected",
|
Self::ClientSelected { .. } => "client_selected",
|
||||||
|
Self::SecurityGroupSelected { .. } => "security_group_selected",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -343,6 +343,19 @@ impl ChatHub {
|
|||||||
Some(sid) => sid,
|
Some(sid) => sid,
|
||||||
None => return Ok(()), // no prior session, nothing to resume
|
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
|
self.resume_session(session_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
/// The built-in admin role — immutable from the API.
|
/// 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 }
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RoleAttrs {
|
||||||
|
pub fn from_opt(attrs: &Option<String>) -> 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<String> {
|
||||||
|
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<bool> {
|
||||||
|
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 ────────────────────────────────────────────────────────────────────
|
// ── Reads ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub async fn list(pool: &SqlitePool) -> Result<Vec<Role>> {
|
pub async fn list(pool: &SqlitePool) -> Result<Vec<Role>> {
|
||||||
@@ -122,3 +197,72 @@ pub async fn seed_admin(pool: &SqlitePool) -> Result<()> {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ pub mod run_context;
|
|||||||
pub mod secrets;
|
pub mod secrets;
|
||||||
pub mod service_manager;
|
pub mod service_manager;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
pub mod setup;
|
||||||
pub mod tic;
|
pub mod tic;
|
||||||
pub mod tool_catalog;
|
pub mod tool_catalog;
|
||||||
pub mod tool_discovery;
|
pub mod tool_discovery;
|
||||||
|
|||||||
@@ -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<RunContext>),
|
||||||
|
/// 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<RunContext>,
|
||||||
|
) -> Result<RunContextDecision> {
|
||||||
|
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 {
|
pub struct RunContextManager {
|
||||||
db: Arc<SqlitePool>,
|
db: Arc<SqlitePool>,
|
||||||
approval: Arc<ApprovalManager>,
|
approval: Arc<ApprovalManager>,
|
||||||
@@ -373,4 +420,61 @@ mod tests {
|
|||||||
|
|
||||||
std::fs::remove_dir_all(&wd).ok();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<RoleSeed>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<SeedProfile> {
|
||||||
|
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<SeedProfile> {
|
||||||
|
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<String> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,13 +25,9 @@ use std::io::{self, IsTerminal, Write};
|
|||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use skald_core::db::{self, SYSTEM_DB_PATH};
|
use skald_core::db::{self, SYSTEM_DB_PATH};
|
||||||
|
use skald_core::setup::{self, FirstAdmin};
|
||||||
use skald_core::users::UserManager;
|
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
|
/// 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
|
/// 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,
|
/// 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");
|
println!("\nWelcome to Skald. Let's create the first user (the admin).\n");
|
||||||
|
|
||||||
|
let profile = prompt_profile()?;
|
||||||
let username = prompt_username()?;
|
let username = prompt_username()?;
|
||||||
let display_name = prompt_line("Display name (optional): ")?;
|
let display_name = prompt_line("Display name (optional): ")?;
|
||||||
let display_name = display_name.trim();
|
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 encrypt = prompt_encrypt()?;
|
||||||
let password = prompt_new_password()?;
|
let password = prompt_new_password()?;
|
||||||
|
|
||||||
let id = users
|
// The shared seam both setup shells call: seed the chosen profile's roles,
|
||||||
.register_user(&username, display_name, ADMIN_ROLE, Some(&password), encrypt)
|
// 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
|
.await
|
||||||
.context("creating the admin user")?;
|
.context("initializing the instance")?;
|
||||||
|
|
||||||
// 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")?;
|
|
||||||
|
|
||||||
println!("\n✓ Admin user '{username}' created (id {id}).");
|
println!("\n✓ Admin user '{username}' created (id {id}).");
|
||||||
if encrypt {
|
if encrypt {
|
||||||
@@ -169,6 +173,34 @@ async fn step_first_user(users: &UserManager, pool: &sqlx::SqlitePool, has_admin
|
|||||||
|
|
||||||
// ── Prompts ────────────────────────────────────────────────────────────────
|
// ── 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<String> {
|
||||||
|
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::<usize>().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<String> {
|
fn prompt_username() -> Result<String> {
|
||||||
loop {
|
loop {
|
||||||
let name = prompt_line("Username: ")?;
|
let name = prompt_line("Username: ")?;
|
||||||
|
|||||||
Executable
+195
@@ -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
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||||
|
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.skald.circle</string>
|
||||||
|
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>${INSTALL_DIR}/run.sh</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<key>WorkingDirectory</key>
|
||||||
|
<string>${INSTALL_DIR}</string>
|
||||||
|
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>${INSTALL_DIR}/logs/stdout.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>${INSTALL_DIR}/logs/stderr.log</string>
|
||||||
|
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>SKALD_BIN</key>
|
||||||
|
<string>${INSTALL_DIR}/bin/skald</string>
|
||||||
|
<key>SKALD_SETUP_BIN</key>
|
||||||
|
<string>${INSTALL_DIR}/bin/skald-setup</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
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"
|
||||||
Executable
+210
@@ -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
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||||
|
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.skald.circle</string>
|
||||||
|
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>${INSTALL_DIR}/run.sh</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<key>WorkingDirectory</key>
|
||||||
|
<string>${INSTALL_DIR}</string>
|
||||||
|
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>${INSTALL_DIR}/logs/stdout.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>${INSTALL_DIR}/logs/stderr.log</string>
|
||||||
|
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>SKALD_BIN</key>
|
||||||
|
<string>${INSTALL_DIR}/bin/skald</string>
|
||||||
|
<key>SKALD_SETUP_BIN</key>
|
||||||
|
<string>${INSTALL_DIR}/bin/skald-setup</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
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"
|
||||||
@@ -109,22 +109,21 @@ pub async fn me(
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads `roles.attrs.ui_mode` for the given role. Any error or missing key
|
/// Reads `roles.attrs.ui_mode` for the given role via the typed [`RoleAttrs`]
|
||||||
/// resolves to "full" — the simplified UI is strictly opt-in.
|
/// (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 {
|
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();
|
return "full".into();
|
||||||
}
|
}
|
||||||
let attrs = skald_core::db::roles::get(skald.db(), role_id)
|
let ui_mode = roles::get(skald.db(), role_id)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.and_then(|r| r.attrs);
|
.map(|r| r.attrs_parsed().ui_mode)
|
||||||
attrs
|
.unwrap_or_default();
|
||||||
.and_then(|a| serde_json::from_str::<serde_json::Value>(&a).ok())
|
ui_mode.as_str().into()
|
||||||
.and_then(|v| v.get("ui_mode")?.as_str().map(str::to_owned))
|
|
||||||
.filter(|m| m == "simple" || m == "full")
|
|
||||||
.unwrap_or_else(|| "full".into())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── POST /api/auth/logout ────────────────────────────────────────────────────
|
// ── POST /api/auth/logout ────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -47,7 +47,12 @@ fn is_public(path: &str) -> bool {
|
|||||||
let p = path.strip_prefix("/api").unwrap_or(path);
|
let p = path.strip_prefix("/api").unwrap_or(path);
|
||||||
matches!(
|
matches!(
|
||||||
p,
|
p,
|
||||||
"/auth/login" | "/auth/logout" | "/auth/me" | "/setup/status" | "/setup/user"
|
"/auth/login"
|
||||||
|
| "/auth/logout"
|
||||||
|
| "/auth/me"
|
||||||
|
| "/setup/status"
|
||||||
|
| "/setup/user"
|
||||||
|
| "/setup/profiles"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ pub fn router() -> Router<Arc<Skald>> {
|
|||||||
.route("/sessions", get(sessions::list_sessions).post(sessions::create))
|
.route("/sessions", get(sessions::list_sessions).post(sessions::create))
|
||||||
// First-run setup
|
// First-run setup
|
||||||
.route("/setup/status", get(setup::status))
|
.route("/setup/status", get(setup::status))
|
||||||
|
.route("/setup/profiles", get(setup::profiles))
|
||||||
.route("/setup/user", post(setup::create_user))
|
.route("/setup/user", post(setup::create_user))
|
||||||
// Auth
|
// Auth
|
||||||
.route("/auth/login", post(auth::login))
|
.route("/auth/login", post(auth::login))
|
||||||
@@ -129,6 +130,8 @@ pub fn router() -> Router<Arc<Skald>> {
|
|||||||
.route("/tool-permission-groups", get(run_context::list_groups).post(run_context::create_group))
|
.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}", put(run_context::update_group).delete(run_context::delete_group))
|
||||||
.route("/tool-permission-groups/{id}/duplicate", post(run_context::duplicate_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)
|
// Session tool_group assignment (runtime)
|
||||||
.route("/sessions/{session_id}/run-context", put(run_context::set_session_run_context))
|
.route("/sessions/{session_id}/run-context", put(run_context::set_session_run_context))
|
||||||
// MCP / Connectors (blueprint §14/§15)
|
// MCP / Connectors (blueprint §14/§15)
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ pub async fn create(
|
|||||||
if body.label.trim().is_empty() {
|
if body.label.trim().is_empty() {
|
||||||
return Err(ApiError::bad_request("label must not be 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())
|
roles::insert(skald.db(), id, body.label.trim(), &body.permission_group, body.attrs.as_deref())
|
||||||
.await?;
|
.await?;
|
||||||
// Seed the standard self-service Connector capabilities (§14): a new role can
|
// 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 {
|
if id == ADMIN_ROLE_ID {
|
||||||
return Err(ApiError::bad_request("the built-in admin role cannot be modified"));
|
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())
|
let ok = roles::update(skald.db(), &id, body.label.trim(), &body.permission_group, body.attrs.as_deref())
|
||||||
.await?;
|
.await?;
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ use axum::{
|
|||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use skald_core::db::roles;
|
||||||
use skald_core::skald::Skald;
|
use skald_core::skald::Skald;
|
||||||
use super::{ApiError, guard::AuthUser, require_context};
|
use super::{ApiError, guard::AuthUser, require_context};
|
||||||
|
|
||||||
@@ -79,6 +80,54 @@ pub async fn duplicate_group(
|
|||||||
Ok(Json(json!({ "id": body.id })))
|
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<Arc<Skald>>,
|
||||||
|
Extension(auth): Extension<AuthUser>,
|
||||||
|
) -> Result<Json<Vec<MySecurityGroup>>, 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>, 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 ────────────────────────────────────────────
|
// ── Session run_context assignment ────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -92,6 +141,30 @@ pub async fn set_session_run_context(
|
|||||||
Json(ctx): Json<Option<skald_core::run_context::RunContext>>,
|
Json(ctx): Json<Option<skald_core::run_context::RunContext>>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let uctx = require_context(&skald, &auth.user_id).await?;
|
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
|
// 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*
|
// persist + live update both target the user's context. Run-context *definitions*
|
||||||
// (roles) remain instance-wide; only the per-session value is owner data.
|
// (roles) remain instance-wide; only the per-session value is owner data.
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use sqlx::SqlitePool;
|
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::db::chat_sessions_stack::SessionStack;
|
||||||
|
use skald_core::run_context::RunContext;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use skald_core::skald::{Skald, UserContext};
|
use skald_core::skald::{Skald, UserContext};
|
||||||
use skald_core::session::handler::ApprovalDecision;
|
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
|
// Resolve agent + RunContext from the source so project chats reset with the
|
||||||
// coordinator agent (not the default `main`), then provision a fresh session.
|
// coordinator agent (not the default `main`), then provision a fresh session.
|
||||||
let (agent, rc) = super::projects::provisioning_for_source(&ctx.pool, &q.source).await?;
|
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?;
|
ctx.chat_hub.provision_session(&q.source, &agent, rc.as_ref(), true).await?;
|
||||||
Ok(Json(json!({})))
|
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<Option<RunContext>, 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 ─────────────────────────────────────────────────────
|
// ── GET /api/web/messages ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub async fn web_messages(
|
pub async fn web_messages(
|
||||||
|
|||||||
+45
-10
@@ -22,6 +22,24 @@ pub async fn status(State(skald): State<Arc<Skald>>) -> Result<Json<SetupStatus>
|
|||||||
Ok(Json(SetupStatus { needs_setup: count == 0 }))
|
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<Vec<SeedProfileInfo>> {
|
||||||
|
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 ────────────────────
|
// ── POST /api/setup/user — create the first (admin) user ────────────────────
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -33,6 +51,9 @@ pub struct CreateUserBody {
|
|||||||
/// Chosen interface language — becomes the instance default (`ui_locale`).
|
/// Chosen interface language — becomes the instance default (`ui_locale`).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub locale: Option<String>,
|
pub locale: Option<String>,
|
||||||
|
/// Chosen seed profile id. Defaults to the first shipped profile.
|
||||||
|
#[serde(default)]
|
||||||
|
pub profile: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -63,17 +84,31 @@ pub async fn create_user(
|
|||||||
return Err(ApiError::bad_request("unsupported locale"));
|
return Err(ApiError::bad_request("unsupported locale"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let profile = body
|
||||||
let id = skald
|
.profile
|
||||||
.users()
|
.as_deref()
|
||||||
.register_user(username, None, "admin", Some(&body.password), body.encrypted)
|
.map(str::trim)
|
||||||
.await?;
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or("family");
|
||||||
// The first-run language choice is instance-wide: it lands in the registry
|
if skald_core::setup::seed_profile(profile).is_none() {
|
||||||
// config as the default every user follows until they override it.
|
return Err(ApiError::bad_request("unknown seed profile"));
|
||||||
if let Some(l) = locale {
|
|
||||||
skald.config().set(skald_core::i18n::DEFAULT_LOCALE_KEY, l).await?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 }))
|
Ok(Json(CreateUserResult { user_id: id }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,23 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String,
|
|||||||
running: session_handler.is_processing(),
|
running: session_handler.is_processing(),
|
||||||
})).await;
|
})).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 {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
// ── Inbound: message from the browser ────────────────────────────
|
// ── Inbound: message from the browser ────────────────────────────
|
||||||
@@ -151,6 +168,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String,
|
|||||||
if handle_question_answer_msg(&text, &session_handler).await { continue; }
|
if handle_question_answer_msg(&text, &session_handler).await { continue; }
|
||||||
if handle_data_msg(&text, &skald) { continue; }
|
if handle_data_msg(&text, &skald) { continue; }
|
||||||
if handle_select_client_msg(&text, &source, &chat_hub).await { 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 ──────────────────────────────────────────────────
|
// ── /sethome ──────────────────────────────────────────────────
|
||||||
let client_msg: ClientMessage = match serde_json::from_str(&text) {
|
let client_msg: ClientMessage = match serde_json::from_str(&text) {
|
||||||
@@ -414,6 +432,13 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String,
|
|||||||
Err(broadcast::error::RecvError::Closed) => return,
|
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
|
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<Skald>,
|
||||||
|
ctx: &Arc<skald_core::skald::UserContext>,
|
||||||
|
session_handler: &Arc<skald_core::session::handler::ChatSessionHandler>,
|
||||||
|
) -> bool {
|
||||||
|
use skald_core::run_context::{RunContext, RunContextDecision, validate_run_context_for_role};
|
||||||
|
|
||||||
|
let Ok(v) = serde_json::from_str::<Value>(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`).
|
/// Returns true if the message was an inbound data push (caller should `continue`).
|
||||||
/// Dispatches `{"type":"data","stream":"...","payload":{...}}` to the appropriate manager.
|
/// Dispatches `{"type":"data","stream":"...","payload":{...}}` to the appropriate manager.
|
||||||
fn handle_data_msg(text: &str, skald: &Arc<Skald>) -> bool {
|
fn handle_data_msg(text: &str, skald: &Arc<Skald>) -> bool {
|
||||||
|
|||||||
+111
@@ -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 ""
|
||||||
@@ -25,6 +25,7 @@ export class AppCopilot extends I18nMixin(ChatSession) {
|
|||||||
_mode: { state: true },
|
_mode: { state: true },
|
||||||
_me: { state: true },
|
_me: { state: true },
|
||||||
_modelOpen: { state: true },
|
_modelOpen: { state: true },
|
||||||
|
_groupOpen: { state: true },
|
||||||
_tabs: { state: true },
|
_tabs: { state: true },
|
||||||
_activeSource: { state: true },
|
_activeSource: { state: true },
|
||||||
_cmdMenu: { state: true },
|
_cmdMenu: { state: true },
|
||||||
@@ -38,6 +39,7 @@ export class AppCopilot extends I18nMixin(ChatSession) {
|
|||||||
this._mode = 'dock';
|
this._mode = 'dock';
|
||||||
this._me = null;
|
this._me = null;
|
||||||
this._modelOpen = false;
|
this._modelOpen = false;
|
||||||
|
this._groupOpen = false;
|
||||||
this._resizing = false;
|
this._resizing = false;
|
||||||
// Slash-command autocomplete: `_cmdMenu` is the filtered list currently shown
|
// Slash-command autocomplete: `_cmdMenu` is the filtered list currently shown
|
||||||
// (null = hidden), `_cmdSel` the highlighted index, `_allCommands` the merged
|
// (null = hidden), `_cmdSel` the highlighted index, `_allCommands` the merged
|
||||||
@@ -62,6 +64,7 @@ export class AppCopilot extends I18nMixin(ChatSession) {
|
|||||||
this._restoreState();
|
this._restoreState();
|
||||||
this._loadCommands();
|
this._loadCommands();
|
||||||
this._loadMe();
|
this._loadMe();
|
||||||
|
this._loadSecurityGroups();
|
||||||
// Same element, two layouts: the chat is the home page ('full') and docks
|
// 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.
|
// to the side on every other route — state is never lost, it only resizes.
|
||||||
this._applyMode(this._pageFromHash() === 'home' ? 'full' : 'dock');
|
this._applyMode(this._pageFromHash() === 'home' ? 'full' : 'dock');
|
||||||
@@ -450,6 +453,29 @@ export class AppCopilot extends I18nMixin(ChatSession) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
` : nothing}
|
` : nothing}
|
||||||
|
${this._securityGroups.length > 1 ? html`
|
||||||
|
<div class="copilot-model-wrap">
|
||||||
|
${this._groupOpen ? html`
|
||||||
|
<div class="copilot-model-overlay" @click=${() => { this._groupOpen = false; }}></div>
|
||||||
|
<div class="copilot-model-dropdown">
|
||||||
|
${this._securityGroups.map(g => html`
|
||||||
|
<button
|
||||||
|
class="copilot-model-item ${g.id === this._selectedGroup ? 'active' : ''}"
|
||||||
|
@click=${() => { this._selectGroup(g.id); this._groupOpen = false; }}
|
||||||
|
>${g.name}</button>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
` : nothing}
|
||||||
|
<button
|
||||||
|
class="copilot-model-pill"
|
||||||
|
title=${t('chat.security_group')}
|
||||||
|
@click=${() => { this._groupOpen = !this._groupOpen; }}>
|
||||||
|
<i class="bi bi-shield-lock"></i>
|
||||||
|
<span>${this._securityGroups.find(g => g.id === this._selectedGroup)?.name ?? this._selectedGroup}</span>
|
||||||
|
<i class="bi bi-chevron-${this._groupOpen ? 'down' : 'up'}"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
` : nothing}
|
||||||
<button
|
<button
|
||||||
class="copilot-toolbar-btn"
|
class="copilot-toolbar-btn"
|
||||||
title=${t('chat.new_session')}
|
title=${t('chat.new_session')}
|
||||||
|
|||||||
@@ -67,10 +67,21 @@ export class RolesPage extends LightElement {
|
|||||||
catch { return 'full'; }
|
catch { return 'full'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
_mergeAttrs(attrs, uiMode) {
|
// Extra security-groups the role may pick beyond its default `permission_group`
|
||||||
|
// (the effective set is default ∪ these). Lives in attrs JSON (§0.1).
|
||||||
|
_attrsAllowedGroups(attrs) {
|
||||||
|
try {
|
||||||
|
const a = JSON.parse(attrs || '{}').permission_groups;
|
||||||
|
return Array.isArray(a) ? a : [];
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
_mergeAttrs(attrs, uiMode, allowedGroups) {
|
||||||
let o = {};
|
let o = {};
|
||||||
try { o = JSON.parse(attrs || '{}') ?? {}; } catch { o = {}; }
|
try { o = JSON.parse(attrs || '{}') ?? {}; } catch { o = {}; }
|
||||||
if (uiMode === 'simple') o.ui_mode = 'simple'; else delete o.ui_mode;
|
if (uiMode === 'simple') o.ui_mode = 'simple'; else delete o.ui_mode;
|
||||||
|
const extras = Array.isArray(allowedGroups) ? allowedGroups.filter(Boolean) : [];
|
||||||
|
if (extras.length) o.permission_groups = extras; else delete o.permission_groups;
|
||||||
const keys = Object.keys(o);
|
const keys = Object.keys(o);
|
||||||
return keys.length ? JSON.stringify(o) : null;
|
return keys.length ? JSON.stringify(o) : null;
|
||||||
}
|
}
|
||||||
@@ -78,7 +89,7 @@ export class RolesPage extends LightElement {
|
|||||||
_openCreate() {
|
_openCreate() {
|
||||||
this._modal = {
|
this._modal = {
|
||||||
mode: 'create',
|
mode: 'create',
|
||||||
form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '', ui_mode: 'full' },
|
form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '', ui_mode: 'full', allowed_groups: [] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +97,7 @@ export class RolesPage extends LightElement {
|
|||||||
this._modal = {
|
this._modal = {
|
||||||
mode: 'edit',
|
mode: 'edit',
|
||||||
role,
|
role,
|
||||||
form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '', ui_mode: this._attrsUiMode(role.attrs) },
|
form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '', ui_mode: this._attrsUiMode(role.attrs), allowed_groups: this._attrsAllowedGroups(role.attrs) },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +107,12 @@ export class RolesPage extends LightElement {
|
|||||||
this._modal = { ...this._modal, form: { ...this._modal.form, [field]: value } };
|
this._modal = { ...this._modal, form: { ...this._modal.form, [field]: value } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_toggleAllowedGroup(id, checked) {
|
||||||
|
const cur = new Set(this._modal.form.allowed_groups || []);
|
||||||
|
if (checked) cur.add(id); else cur.delete(id);
|
||||||
|
this._patch('allowed_groups', [...cur]);
|
||||||
|
}
|
||||||
|
|
||||||
// ── API actions ──────────────────────────────────────────────────────────────
|
// ── API actions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async _save() {
|
async _save() {
|
||||||
@@ -112,7 +129,7 @@ export class RolesPage extends LightElement {
|
|||||||
id: form.id.trim(),
|
id: form.id.trim(),
|
||||||
label: form.label.trim(),
|
label: form.label.trim(),
|
||||||
permission_group: form.permission_group,
|
permission_group: form.permission_group,
|
||||||
attrs: this._mergeAttrs(form.attrs, form.ui_mode),
|
attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
@@ -129,7 +146,7 @@ export class RolesPage extends LightElement {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
label: form.label.trim(),
|
label: form.label.trim(),
|
||||||
permission_group: form.permission_group,
|
permission_group: form.permission_group,
|
||||||
attrs: this._mergeAttrs(form.attrs, form.ui_mode),
|
attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
@@ -189,6 +206,18 @@ export class RolesPage extends LightElement {
|
|||||||
${(this._groups ?? []).map(g => html`<option value=${g.id} ?selected=${form.permission_group === g.id}>${g.name}</option>`)}
|
${(this._groups ?? []).map(g => html`<option value=${g.id} ?selected=${form.permission_group === g.id}>${g.name}</option>`)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">${t('roles.form.allowed')}</label>
|
||||||
|
<div class="form-text mb-2" style="font-size:.75rem">${t('roles.form.allowed_hint')}</div>
|
||||||
|
${(this._groups ?? []).filter(g => g.id !== form.permission_group).map(g => html`
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="allow-${g.id}"
|
||||||
|
.checked=${(form.allowed_groups || []).includes(g.id)}
|
||||||
|
@change=${e => this._toggleAllowedGroup(g.id, e.target.checked)} />
|
||||||
|
<label class="form-check-label" for="allow-${g.id}">${g.name}</label>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">${t('roles.form.interface')}</label>
|
<label class="form-label">${t('roles.form.interface')}</label>
|
||||||
<select class="form-select" @change=${e => this._patch('ui_mode', e.target.value)}>
|
<select class="form-select" @change=${e => this._patch('ui_mode', e.target.value)}>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export class SetupPage extends I18nMixin(LightElement) {
|
|||||||
_confirm: { state: true },
|
_confirm: { state: true },
|
||||||
_encrypted: { state: true },
|
_encrypted: { state: true },
|
||||||
_locale: { state: true },
|
_locale: { state: true },
|
||||||
|
_profiles: { state: true },
|
||||||
|
_profile: { state: true },
|
||||||
_error: { state: true },
|
_error: { state: true },
|
||||||
_busy: { state: true },
|
_busy: { state: true },
|
||||||
};
|
};
|
||||||
@@ -23,10 +25,29 @@ export class SetupPage extends I18nMixin(LightElement) {
|
|||||||
this._confirm = '';
|
this._confirm = '';
|
||||||
this._encrypted = true;
|
this._encrypted = true;
|
||||||
this._locale = getLocale();
|
this._locale = getLocale();
|
||||||
|
this._profiles = [];
|
||||||
|
this._profile = 'family';
|
||||||
this._error = null;
|
this._error = null;
|
||||||
this._busy = false;
|
this._busy = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
this._loadProfiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _loadProfiles() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/setup/profiles');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const list = await res.json();
|
||||||
|
if (Array.isArray(list) && list.length) {
|
||||||
|
this._profiles = list;
|
||||||
|
this._profile = list[0].id;
|
||||||
|
}
|
||||||
|
} catch { /* one preset ships; a failed fetch just keeps the default */ }
|
||||||
|
}
|
||||||
|
|
||||||
_submit(e) {
|
_submit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (this._busy) return;
|
if (this._busy) return;
|
||||||
@@ -60,6 +81,7 @@ export class SetupPage extends I18nMixin(LightElement) {
|
|||||||
password: this._password,
|
password: this._password,
|
||||||
encrypted: this._encrypted,
|
encrypted: this._encrypted,
|
||||||
locale: this._locale,
|
locale: this._locale,
|
||||||
|
profile: this._profile,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -92,6 +114,21 @@ export class SetupPage extends I18nMixin(LightElement) {
|
|||||||
|
|
||||||
${this._error ? html`<div class="setup-error">${this._error}</div>` : null}
|
${this._error ? html`<div class="setup-error">${this._error}</div>` : null}
|
||||||
|
|
||||||
|
${this._profiles.length > 1 ? html`
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">${t('setup.profile')}</label>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
.value=${this._profile}
|
||||||
|
@change=${e => this._profile = e.target.value}
|
||||||
|
?disabled=${this._busy}>
|
||||||
|
${this._profiles.map(p => html`
|
||||||
|
<option value=${p.id} ?selected=${this._profile === p.id}>${p.label}</option>
|
||||||
|
`)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
` : null}
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">${t('login.username')}</label>
|
<label class="form-label">${t('login.username')}</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
+5
-1
@@ -59,6 +59,7 @@ export default {
|
|||||||
'chat.thinking': 'Thinking…',
|
'chat.thinking': 'Thinking…',
|
||||||
'chat.attach': 'Attach files',
|
'chat.attach': 'Attach files',
|
||||||
'chat.new_session': 'New conversation',
|
'chat.new_session': 'New conversation',
|
||||||
|
'chat.security_group': 'Security group',
|
||||||
'chat.collapse': 'Hide chat',
|
'chat.collapse': 'Hide chat',
|
||||||
'chat.close_tab': 'Close tab',
|
'chat.close_tab': 'Close tab',
|
||||||
'chat.privacy': 'Private to you',
|
'chat.privacy': 'Private to you',
|
||||||
@@ -306,6 +307,7 @@ export default {
|
|||||||
'setup.pw.mismatch': 'The two passwords do not match.',
|
'setup.pw.mismatch': 'The two passwords do not match.',
|
||||||
'setup.confirm': 'Confirm password',
|
'setup.confirm': 'Confirm password',
|
||||||
'setup.language': 'Interface language',
|
'setup.language': 'Interface language',
|
||||||
|
'setup.profile': 'Instance type',
|
||||||
'setup.encrypt': 'Encrypt my conversation history',
|
'setup.encrypt': 'Encrypt my conversation history',
|
||||||
'setup.warn': 'your password derives the encryption key. If you forget it, your entire conversation history will be permanently lost — there is no recovery.',
|
'setup.warn': 'your password derives the encryption key. If you forget it, your entire conversation history will be permanently lost — there is no recovery.',
|
||||||
'setup.warn.strong': 'Warning:',
|
'setup.warn.strong': 'Warning:',
|
||||||
@@ -670,7 +672,9 @@ export default {
|
|||||||
'roles.form.id_ph': 'e.g. editor',
|
'roles.form.id_ph': 'e.g. editor',
|
||||||
'roles.form.id_desc': 'Lowercase, no spaces. Cannot be changed later.',
|
'roles.form.id_desc': 'Lowercase, no spaces. Cannot be changed later.',
|
||||||
'roles.form.label': 'Label',
|
'roles.form.label': 'Label',
|
||||||
'roles.form.group': 'Permission group',
|
'roles.form.group': 'Default security group',
|
||||||
|
'roles.form.allowed': 'Additional security groups',
|
||||||
|
'roles.form.allowed_hint': 'Groups this role may also switch to at runtime, on top of the default. Users pick from these in chat.',
|
||||||
'roles.form.interface': 'Interface',
|
'roles.form.interface': 'Interface',
|
||||||
'roles.form.interface_full': 'Full — all pages',
|
'roles.form.interface_full': 'Full — all pages',
|
||||||
'roles.form.interface_simple': 'Simple — chat only',
|
'roles.form.interface_simple': 'Simple — chat only',
|
||||||
|
|||||||
+5
-1
@@ -59,6 +59,7 @@ export default {
|
|||||||
'chat.thinking': 'Réflexion…',
|
'chat.thinking': 'Réflexion…',
|
||||||
'chat.attach': 'Joindre des fichiers',
|
'chat.attach': 'Joindre des fichiers',
|
||||||
'chat.new_session': 'Nouvelle conversation',
|
'chat.new_session': 'Nouvelle conversation',
|
||||||
|
'chat.security_group': 'Groupe de sécurité',
|
||||||
'chat.collapse': 'Masquer la discussion',
|
'chat.collapse': 'Masquer la discussion',
|
||||||
'chat.close_tab': 'Fermer l\'onglet',
|
'chat.close_tab': 'Fermer l\'onglet',
|
||||||
'chat.privacy': 'Privé pour vous',
|
'chat.privacy': 'Privé pour vous',
|
||||||
@@ -306,6 +307,7 @@ export default {
|
|||||||
'setup.pw.mismatch': 'Les deux mots de passe ne correspondent pas.',
|
'setup.pw.mismatch': 'Les deux mots de passe ne correspondent pas.',
|
||||||
'setup.confirm': 'Confirmer le mot de passe',
|
'setup.confirm': 'Confirmer le mot de passe',
|
||||||
'setup.language': 'Langue de l\'interface',
|
'setup.language': 'Langue de l\'interface',
|
||||||
|
'setup.profile': 'Type d\'instance',
|
||||||
'setup.encrypt': 'Chiffrer mon historique de conversations',
|
'setup.encrypt': 'Chiffrer mon historique de conversations',
|
||||||
'setup.warn': 'votre mot de passe génère la clé de chiffrement. Si vous l\'oubliez, tout votre historique de conversations sera définitivement perdu — il n\'y a aucune récupération possible.',
|
'setup.warn': 'votre mot de passe génère la clé de chiffrement. Si vous l\'oubliez, tout votre historique de conversations sera définitivement perdu — il n\'y a aucune récupération possible.',
|
||||||
'setup.warn.strong': 'Attention :',
|
'setup.warn.strong': 'Attention :',
|
||||||
@@ -670,7 +672,9 @@ export default {
|
|||||||
'roles.form.id_ph': 'ex. redacteur',
|
'roles.form.id_ph': 'ex. redacteur',
|
||||||
'roles.form.id_desc': 'Minuscules, sans espaces. Ne peut pas être modifié ultérieurement.',
|
'roles.form.id_desc': 'Minuscules, sans espaces. Ne peut pas être modifié ultérieurement.',
|
||||||
'roles.form.label': 'Libellé',
|
'roles.form.label': 'Libellé',
|
||||||
'roles.form.group': 'Groupe de permissions',
|
'roles.form.group': 'Groupe de sécurité par défaut',
|
||||||
|
'roles.form.allowed': 'Groupes de sécurité supplémentaires',
|
||||||
|
'roles.form.allowed_hint': 'Groupes que ce rôle peut aussi choisir à l\'exécution, en plus du groupe par défaut. Les utilisateurs les sélectionnent dans le chat.',
|
||||||
'roles.form.interface': 'Interface',
|
'roles.form.interface': 'Interface',
|
||||||
'roles.form.interface_full': 'Complet — toutes les pages',
|
'roles.form.interface_full': 'Complet — toutes les pages',
|
||||||
'roles.form.interface_simple': 'Simple — discussion uniquement',
|
'roles.form.interface_simple': 'Simple — discussion uniquement',
|
||||||
|
|||||||
+5
-1
@@ -59,6 +59,7 @@ export default {
|
|||||||
'chat.thinking': 'Sto pensando…',
|
'chat.thinking': 'Sto pensando…',
|
||||||
'chat.attach': 'Allega file',
|
'chat.attach': 'Allega file',
|
||||||
'chat.new_session': 'Nuova conversazione',
|
'chat.new_session': 'Nuova conversazione',
|
||||||
|
'chat.security_group': 'Gruppo di sicurezza',
|
||||||
'chat.collapse': 'Nascondi la chat',
|
'chat.collapse': 'Nascondi la chat',
|
||||||
'chat.close_tab': 'Chiudi scheda',
|
'chat.close_tab': 'Chiudi scheda',
|
||||||
'chat.privacy': 'Privata',
|
'chat.privacy': 'Privata',
|
||||||
@@ -306,6 +307,7 @@ export default {
|
|||||||
'setup.pw.mismatch': 'Le due password non coincidono.',
|
'setup.pw.mismatch': 'Le due password non coincidono.',
|
||||||
'setup.confirm': 'Conferma password',
|
'setup.confirm': 'Conferma password',
|
||||||
'setup.language': 'Lingua dell\'interfaccia',
|
'setup.language': 'Lingua dell\'interfaccia',
|
||||||
|
'setup.profile': 'Tipo di istanza',
|
||||||
'setup.encrypt': 'Cifra la cronologia delle mie conversazioni',
|
'setup.encrypt': 'Cifra la cronologia delle mie conversazioni',
|
||||||
'setup.warn': 'la tua password genera la chiave di cifratura. Se la dimentichi, l\'intera cronologia delle conversazioni andrà persa per sempre — non esiste alcun recupero.',
|
'setup.warn': 'la tua password genera la chiave di cifratura. Se la dimentichi, l\'intera cronologia delle conversazioni andrà persa per sempre — non esiste alcun recupero.',
|
||||||
'setup.warn.strong': 'Attenzione:',
|
'setup.warn.strong': 'Attenzione:',
|
||||||
@@ -670,7 +672,9 @@ export default {
|
|||||||
'roles.form.id_ph': 'es. editor',
|
'roles.form.id_ph': 'es. editor',
|
||||||
'roles.form.id_desc': 'Minuscolo, senza spazi. Non può essere modificato in seguito.',
|
'roles.form.id_desc': 'Minuscolo, senza spazi. Non può essere modificato in seguito.',
|
||||||
'roles.form.label': 'Etichetta',
|
'roles.form.label': 'Etichetta',
|
||||||
'roles.form.group': 'Gruppo di permessi',
|
'roles.form.group': 'Gruppo di sicurezza predefinito',
|
||||||
|
'roles.form.allowed': 'Gruppi di sicurezza aggiuntivi',
|
||||||
|
'roles.form.allowed_hint': 'Gruppi a cui questo ruolo può passare a runtime, oltre al predefinito. Gli utenti li scelgono in chat.',
|
||||||
'roles.form.interface': 'Interfaccia',
|
'roles.form.interface': 'Interfaccia',
|
||||||
'roles.form.interface_full': 'Completa — tutte le pagine',
|
'roles.form.interface_full': 'Completa — tutte le pagine',
|
||||||
'roles.form.interface_simple': 'Semplice — solo chat',
|
'roles.form.interface_simple': 'Semplice — solo chat',
|
||||||
|
|||||||
+98
-1
@@ -31,6 +31,11 @@ export class ChatSession extends LightElement {
|
|||||||
_providers: { state: true },
|
_providers: { state: true },
|
||||||
_selectedClient: { state: true },
|
_selectedClient: { state: true },
|
||||||
_providersLoaded: { state: true },
|
_providersLoaded: { state: true },
|
||||||
|
// Session security-group (permission group) picker — the twin of the model
|
||||||
|
// pill. `_securityGroups` is the caller's selectable set; `_selectedGroup` is
|
||||||
|
// the session's current group (backend is the source of truth).
|
||||||
|
_securityGroups: { state: true },
|
||||||
|
_selectedGroup: { state: true },
|
||||||
_rejectingId: { state: true },
|
_rejectingId: { state: true },
|
||||||
_rejectNote: { state: true },
|
_rejectNote: { state: true },
|
||||||
_clarificationAnswer: { state: true },
|
_clarificationAnswer: { state: true },
|
||||||
@@ -54,9 +59,16 @@ export class ChatSession extends LightElement {
|
|||||||
this._waiting = false;
|
this._waiting = false;
|
||||||
this._expanded = new Set();
|
this._expanded = new Set();
|
||||||
this._ws = null;
|
this._ws = null;
|
||||||
|
// True only for an auto-reconnect after an unexpected socket close (set in
|
||||||
|
// `onclose`), so the next `onopen` reconciles tool state that may have advanced
|
||||||
|
// while we were disconnected. A deliberate teardown (source switch / new session)
|
||||||
|
// nulls `onclose` first, so it never sets this.
|
||||||
|
this._reconnecting = false;
|
||||||
this._providers = [];
|
this._providers = [];
|
||||||
this._selectedClient = null;
|
this._selectedClient = null;
|
||||||
this._providersLoaded = false;
|
this._providersLoaded = false;
|
||||||
|
this._securityGroups = [];
|
||||||
|
this._selectedGroup = 'default';
|
||||||
this._rejectingId = null;
|
this._rejectingId = null;
|
||||||
this._rejectNote = '';
|
this._rejectNote = '';
|
||||||
this._clarificationAnswer = '';
|
this._clarificationAnswer = '';
|
||||||
@@ -174,13 +186,63 @@ export class ChatSession extends LightElement {
|
|||||||
const ws = new WebSocket(`${proto}://${location.host}/api/ws?source=${this._source}`);
|
const ws = new WebSocket(`${proto}://${location.host}/api/ws?source=${this._source}`);
|
||||||
this._ws = ws;
|
this._ws = ws;
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
|
// After an auto-reconnect, reconcile tool state: a terminal event
|
||||||
|
// (tool_done / tool_error) delivered while the socket was down is lost —
|
||||||
|
// the server bus is a broadcast with no replay — so a card could otherwise
|
||||||
|
// stay 'running' forever until a manual reload. Re-fetch history and advance
|
||||||
|
// any locally-unfinished card that has since reached a terminal state.
|
||||||
|
if (this._reconnecting) {
|
||||||
|
this._reconnecting = false;
|
||||||
|
this._resyncOnReconnect();
|
||||||
|
}
|
||||||
if (this._hasPendingTools) {
|
if (this._hasPendingTools) {
|
||||||
ws.send(JSON.stringify({ type: 'resume' }));
|
ws.send(JSON.stringify({ type: 'resume' }));
|
||||||
this._hasPendingTools = false;
|
this._hasPendingTools = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
ws.onmessage = (ev) => this._handleServerMsg(JSON.parse(ev.data));
|
ws.onmessage = (ev) => this._handleServerMsg(JSON.parse(ev.data));
|
||||||
ws.onclose = () => setTimeout(() => this._connectWS(), 2000);
|
ws.onclose = () => { this._reconnecting = true; setTimeout(() => this._connectWS(), 2000); };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile tool cards after an unexpected reconnect. Re-fetches the server's
|
||||||
|
* message history and advances any locally-unfinished tool card (`running` or
|
||||||
|
* `pending`) whose server row has reached a **terminal** state while the socket
|
||||||
|
* was down. Only ever moves a card *forward* to a terminal state: a tool still
|
||||||
|
* executing reads as `pending`/Interrupted in history and is deliberately left
|
||||||
|
* untouched, so this never re-shows a spinner or an approval form for live work.
|
||||||
|
*/
|
||||||
|
async _resyncOnReconnect() {
|
||||||
|
let items;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/${this._source}/messages`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
items = await res.json();
|
||||||
|
} catch { return; }
|
||||||
|
// Terminal from the history projection: 'done', or a genuine 'error' (a tool
|
||||||
|
// that was merely interrupted mid-run surfaces as error 'Interrupted.' and is
|
||||||
|
// NOT terminal — it may still be executing).
|
||||||
|
const isTerminal = (it) =>
|
||||||
|
it.kind === 'tool' &&
|
||||||
|
(it.status === 'done' || (it.status === 'error' && it.error !== 'Interrupted.'));
|
||||||
|
for (const it of items) {
|
||||||
|
if (!isTerminal(it)) continue;
|
||||||
|
const local = this._messages.find(
|
||||||
|
m => m.kind === 'tool' && m.tool_call_id === it.tool_call_id
|
||||||
|
);
|
||||||
|
if (!local || (local.status !== 'running' && local.status !== 'pending')) continue;
|
||||||
|
this._updateTool(it.tool_call_id, {
|
||||||
|
status: it.status,
|
||||||
|
result: it.result,
|
||||||
|
result_type: it.result_type,
|
||||||
|
error: it.error,
|
||||||
|
request_id: null,
|
||||||
|
});
|
||||||
|
// Collapse a resolved approval form.
|
||||||
|
const expanded = new Set(this._expanded);
|
||||||
|
expanded.delete(it.tool_call_id);
|
||||||
|
this._expanded = expanded;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _startNewSession() {
|
async _startNewSession() {
|
||||||
@@ -397,6 +459,14 @@ export class ChatSession extends LightElement {
|
|||||||
this._selectedClient = msg.client;
|
this._selectedClient = msg.client;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'security_group_selected':
|
||||||
|
// Twin of `client_selected`: the backend is the source of truth for the
|
||||||
|
// session's security-group. Arrives on connect (initial state) and on
|
||||||
|
// every change (this tab, another tab, or a role-default), so the picker
|
||||||
|
// stays in sync. Direct set — Lit re-renders (`_selectedGroup` is state).
|
||||||
|
this._selectedGroup = msg.group;
|
||||||
|
break;
|
||||||
|
|
||||||
case 'llm_failed':
|
case 'llm_failed':
|
||||||
this._waiting = false;
|
this._waiting = false;
|
||||||
this._pushError(`LLM unavailable. Tried: ${msg.tried.join(', ')}. ${msg.last_error}`);
|
this._pushError(`LLM unavailable. Tried: ${msg.tried.join(', ')}. ${msg.last_error}`);
|
||||||
@@ -559,6 +629,33 @@ export class ChatSession extends LightElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the caller's selectable security-groups (the role's effective set). One
|
||||||
|
* fetch; the current selection arrives over the WS (`security_group_selected`),
|
||||||
|
* so this only feeds the dropdown's options.
|
||||||
|
*/
|
||||||
|
async _loadSecurityGroups() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/my/security-groups');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const list = await res.json();
|
||||||
|
if (Array.isArray(list)) this._securityGroups = list;
|
||||||
|
} catch { /* the picker just stays hidden if this fails */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a security-group for the current session. Mirrors [`_selectClient`]: set
|
||||||
|
* locally for instant feedback, then notify the backend, which validates against
|
||||||
|
* the role, persists on the session, and broadcasts `security_group_selected`
|
||||||
|
* back to every client (this tab included) so the picker re-syncs from truth.
|
||||||
|
*/
|
||||||
|
_selectGroup(group) {
|
||||||
|
this._selectedGroup = group;
|
||||||
|
if (this._ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this._ws.send(JSON.stringify({ type: 'select_security_group', group }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_cancel() {
|
_cancel() {
|
||||||
if (this._ws?.readyState === WebSocket.OPEN) {
|
if (this._ws?.readyState === WebSocket.OPEN) {
|
||||||
this._ws.send(JSON.stringify({ type: 'cancel' }));
|
this._ws.send(JSON.stringify({ type: 'cancel' }));
|
||||||
|
|||||||
Reference in New Issue
Block a user