From 9726f032dadb9f238533334ccbd028321ea50955 Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Sun, 19 Jul 2026 22:40:50 +0100 Subject: [PATCH 01/42] Fix: replace actions/checkout with direct git commands (no Node.js in runner) --- .gitea/workflows/nightly.yml | 9 ++++++++- .gitea/workflows/release.yml | 18 ++++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index b4f31b6..e4e6cac 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -11,7 +11,14 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + run: | + # No Node.js available in runner container — use git directly + cd "${GITHUB_WORKSPACE:-.}" + git init + git remote add origin http://gitea:3000/dguiducci/Skald-Circle.git + git fetch --depth 1 origin "${GITHUB_REF_NAME:-main}" + git checkout FETCH_HEAD + git submodule update --init --recursive 2>/dev/null || true - name: Build native (linux/amd64) run: ./build.sh diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index d14327a..a6c123f 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -16,7 +16,14 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + run: | + # No Node.js available in runner container — use git directly + cd "${GITHUB_WORKSPACE:-.}" + git init + git remote add origin http://gitea:3000/dguiducci/Skald-Circle.git + git fetch --depth 1 origin "${GITHUB_REF_NAME:-release}" + git checkout FETCH_HEAD + git submodule update --init --recursive 2>/dev/null || true - name: Verify version is new run: ./scripts/verify-version.sh --builds-dir /var/www/builds.skaldagent.net @@ -31,7 +38,14 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + run: | + # No Node.js available in runner container — use git directly + cd "${GITHUB_WORKSPACE:-.}" + git init + git remote add origin http://gitea:3000/dguiducci/Skald-Circle.git + git fetch --depth 1 origin "${GITHUB_REF_NAME:-release}" + git checkout FETCH_HEAD + git submodule update --init --recursive 2>/dev/null || true - name: Extract version from Cargo.toml id: extract-version From 7d3235c21e2c4b75398c02a90ec44b89ac02b0be Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Sun, 19 Jul 2026 22:45:12 +0100 Subject: [PATCH 02/42] SKALD: update runner info (native v2.1.0) --- SKALD.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SKALD.md b/SKALD.md index 04eac58..5584597 100644 --- a/SKALD.md +++ b/SKALD.md @@ -31,7 +31,7 @@ Tutti gli 11 agenti hanno ora icone in stile **Vector Paintings** (painterly vec ### Auto-build CI/CD ✅ -Build automatica su NiPoGi con Gitea Actions: +Build automatica su NiPoGi con Gitea Actions (runner nativo v2.1.0): | Componente | File | Stato | |---|---|---| From dc7ce0f924f28e56fb51a2f865d46be2199d68b7 Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Sun, 19 Jul 2026 22:46:57 +0100 Subject: [PATCH 03/42] Fix: switch back to actions/checkout@v4 (runner nativo ha Node.js) --- .gitea/workflows/nightly.yml | 10 +--------- .gitea/workflows/release.yml | 20 ++------------------ SKALD.md | 2 +- 3 files changed, 4 insertions(+), 28 deletions(-) diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index e4e6cac..3de52ee 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -10,15 +10,7 @@ jobs: runs-on: linux-amd64 steps: - - name: Checkout code - run: | - # No Node.js available in runner container — use git directly - cd "${GITHUB_WORKSPACE:-.}" - git init - git remote add origin http://gitea:3000/dguiducci/Skald-Circle.git - git fetch --depth 1 origin "${GITHUB_REF_NAME:-main}" - git checkout FETCH_HEAD - git submodule update --init --recursive 2>/dev/null || true + - uses: actions/checkout@v4 - name: Build native (linux/amd64) run: ./build.sh diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index a6c123f..3fb41e4 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -15,15 +15,7 @@ jobs: runs-on: linux-amd64 steps: - - name: Checkout code - run: | - # No Node.js available in runner container — use git directly - cd "${GITHUB_WORKSPACE:-.}" - git init - git remote add origin http://gitea:3000/dguiducci/Skald-Circle.git - git fetch --depth 1 origin "${GITHUB_REF_NAME:-release}" - git checkout FETCH_HEAD - git submodule update --init --recursive 2>/dev/null || true + - uses: actions/checkout@v4 - name: Verify version is new run: ./scripts/verify-version.sh --builds-dir /var/www/builds.skaldagent.net @@ -37,15 +29,7 @@ jobs: version: ${{ steps.extract-version.outputs.version }} steps: - - name: Checkout code - run: | - # No Node.js available in runner container — use git directly - cd "${GITHUB_WORKSPACE:-.}" - git init - git remote add origin http://gitea:3000/dguiducci/Skald-Circle.git - git fetch --depth 1 origin "${GITHUB_REF_NAME:-release}" - git checkout FETCH_HEAD - git submodule update --init --recursive 2>/dev/null || true + - uses: actions/checkout@v4 - name: Extract version from Cargo.toml id: extract-version diff --git a/SKALD.md b/SKALD.md index 5584597..b74407c 100644 --- a/SKALD.md +++ b/SKALD.md @@ -39,7 +39,7 @@ Build automatica su NiPoGi con Gitea Actions (runner nativo v2.1.0): | `scripts/verify-version.sh` | Verifica che una release non sia già buildata | ✅ | | `.gitea/workflows/nightly.yml` | Push su `main` → build amd64+arm64 → nightly/ | ✅ | | `.gitea/workflows/release.yml` | PR check `verify-version` + merge → build → releases/v{ver}/ | ✅ | -| **act_runner** su NiPoGi | Docker, label `linux-amd64`, host mode | ✅ | +| **act_runner** nativo su NiPoGi | v2.1.0, host-mode systemd service | ✅ | | **Cross toolchain** (arm64) | `gcc-aarch64-linux-gnu` + `rustup target add` | ✅ | | **Caddy `builds.skaldagent.net`** | Configurato + directory `/var/www/builds.skaldagent.net/` | ✅ | | **Route53 `builds.skaldagent.net`** | A record → 145.40.169.107 | ✅ | From 9fe742f9b5ec5f33f499fd3f2346a8fc5442b3ee Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Sun, 19 Jul 2026 22:54:11 +0100 Subject: [PATCH 04/42] Fix: disabilita whisper-local su Linux (--no-default-features) --- .gitea/workflows/nightly.yml | 6 +++--- .gitea/workflows/release.yml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index 3de52ee..ac62cb4 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -13,15 +13,15 @@ jobs: - uses: actions/checkout@v4 - name: Build native (linux/amd64) - run: ./build.sh + run: ./build.sh --no-default-features - name: Cross-compile (linux/arm64) env: CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar run: | - cargo build --release --target aarch64-unknown-linux-gnu - cargo build --release -p skald-setup --target aarch64-unknown-linux-gnu + cargo build --release --no-default-features --target aarch64-unknown-linux-gnu + cargo build --release --no-default-features -p skald-setup --target aarch64-unknown-linux-gnu - name: Package amd64 run: | diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 3fb41e4..fdff6a7 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -43,15 +43,15 @@ jobs: run: ./scripts/verify-version.sh --builds-dir /var/www/builds.skaldagent.net - name: Build native (linux/amd64) - run: ./build.sh + run: ./build.sh --no-default-features - name: Cross-compile (linux/arm64) env: CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar run: | - cargo build --release --target aarch64-unknown-linux-gnu - cargo build --release -p skald-setup --target aarch64-unknown-linux-gnu + cargo build --release --no-default-features --target aarch64-unknown-linux-gnu + cargo build --release --no-default-features -p skald-setup --target aarch64-unknown-linux-gnu - name: Package amd64 run: | From 3e2c7d4ae3155ca7ad9c8688c5217b0251dbe794 Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Sun, 19 Jul 2026 23:12:22 +0100 Subject: [PATCH 05/42] Fix: set CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER per cross-compile arm64 --- .gitea/workflows/nightly.yml | 1 + .gitea/workflows/release.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index ac62cb4..88cbbb2 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -19,6 +19,7 @@ jobs: env: CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc run: | cargo build --release --no-default-features --target aarch64-unknown-linux-gnu cargo build --release --no-default-features -p skald-setup --target aarch64-unknown-linux-gnu diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index fdff6a7..3071bea 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -49,6 +49,7 @@ jobs: env: CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc run: | cargo build --release --no-default-features --target aarch64-unknown-linux-gnu cargo build --release --no-default-features -p skald-setup --target aarch64-unknown-linux-gnu From acb4f209983fe5eeeb26141f9c6c93cf7e4a265a Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Sun, 19 Jul 2026 23:14:19 +0100 Subject: [PATCH 06/42] CI: persistent CARGO_TARGET_DIR per cache tra build, cargo diretto invece di build.sh --- .gitea/workflows/nightly.yml | 15 ++++++++++----- .gitea/workflows/release.yml | 15 ++++++++++----- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index 88cbbb2..c9df075 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -9,11 +9,16 @@ jobs: build: runs-on: linux-amd64 + env: + CARGO_TARGET_DIR: /home/dguiducci/.cache/skald-ci/target + steps: - uses: actions/checkout@v4 - name: Build native (linux/amd64) - run: ./build.sh --no-default-features + run: | + RUSTFLAGS="-A warnings" cargo build --release --no-default-features + RUSTFLAGS="-A warnings" cargo build --release --no-default-features -p skald-setup - name: Cross-compile (linux/arm64) env: @@ -21,15 +26,15 @@ jobs: AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc run: | - cargo build --release --no-default-features --target aarch64-unknown-linux-gnu - cargo build --release --no-default-features -p skald-setup --target aarch64-unknown-linux-gnu + RUSTFLAGS="-A warnings" cargo build --release --no-default-features --target aarch64-unknown-linux-gnu + RUSTFLAGS="-A warnings" cargo build --release --no-default-features -p skald-setup --target aarch64-unknown-linux-gnu - name: Package amd64 run: | ./scripts/package.sh \ --version nightly \ --arch amd64 \ - --target-dir target/release \ + --target-dir /home/dguiducci/.cache/skald-ci/target/release \ --output dist/ - name: Package arm64 @@ -37,7 +42,7 @@ jobs: ./scripts/package.sh \ --version nightly \ --arch arm64 \ - --target-dir target/aarch64-unknown-linux-gnu/release \ + --target-dir /home/dguiducci/.cache/skald-ci/target/aarch64-unknown-linux-gnu/release \ --output dist/ - name: Deploy to builds.skaldagent.net diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 3071bea..0cedf8d 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -28,6 +28,9 @@ jobs: outputs: version: ${{ steps.extract-version.outputs.version }} + env: + CARGO_TARGET_DIR: /home/dguiducci/.cache/skald-ci/target + steps: - uses: actions/checkout@v4 @@ -43,7 +46,9 @@ jobs: run: ./scripts/verify-version.sh --builds-dir /var/www/builds.skaldagent.net - name: Build native (linux/amd64) - run: ./build.sh --no-default-features + run: | + RUSTFLAGS="-A warnings" cargo build --release --no-default-features + RUSTFLAGS="-A warnings" cargo build --release --no-default-features -p skald-setup - name: Cross-compile (linux/arm64) env: @@ -51,15 +56,15 @@ jobs: AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc run: | - cargo build --release --no-default-features --target aarch64-unknown-linux-gnu - cargo build --release --no-default-features -p skald-setup --target aarch64-unknown-linux-gnu + RUSTFLAGS="-A warnings" cargo build --release --no-default-features --target aarch64-unknown-linux-gnu + RUSTFLAGS="-A warnings" cargo build --release --no-default-features -p skald-setup --target aarch64-unknown-linux-gnu - name: Package amd64 run: | ./scripts/package.sh \ --version "${{ steps.extract-version.outputs.version }}" \ --arch amd64 \ - --target-dir target/release \ + --target-dir /home/dguiducci/.cache/skald-ci/target/release \ --output dist/ - name: Package arm64 @@ -67,7 +72,7 @@ jobs: ./scripts/package.sh \ --version "${{ steps.extract-version.outputs.version }}" \ --arch arm64 \ - --target-dir target/aarch64-unknown-linux-gnu/release \ + --target-dir /home/dguiducci/.cache/skald-ci/target/aarch64-unknown-linux-gnu/release \ --output dist/ - name: Deploy to builds.skaldagent.net From 608ce7d513fc0040486a273ba5e4d93c3a32357e Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Sun, 19 Jul 2026 23:36:02 +0100 Subject: [PATCH 07/42] Fix: cd GITHUB_WORKSPACE in package/deploy steps --- .gitea/workflows/nightly.yml | 3 +++ .gitea/workflows/release.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index c9df075..c38ce0a 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -31,6 +31,7 @@ jobs: - name: Package amd64 run: | + cd "${GITHUB_WORKSPACE:-.}" ./scripts/package.sh \ --version nightly \ --arch amd64 \ @@ -39,6 +40,7 @@ jobs: - name: Package arm64 run: | + cd "${GITHUB_WORKSPACE:-.}" ./scripts/package.sh \ --version nightly \ --arch arm64 \ @@ -47,6 +49,7 @@ jobs: - name: Deploy to builds.skaldagent.net run: | + cd "${GITHUB_WORKSPACE:-.}" mkdir -p /var/www/builds.skaldagent.net/nightly cp dist/*.tar.gz /var/www/builds.skaldagent.net/nightly/ echo "[nightly] Deployed:" diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 0cedf8d..4add902 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -61,6 +61,7 @@ jobs: - name: Package amd64 run: | + cd "${GITHUB_WORKSPACE:-.}" ./scripts/package.sh \ --version "${{ steps.extract-version.outputs.version }}" \ --arch amd64 \ @@ -69,6 +70,7 @@ jobs: - name: Package arm64 run: | + cd "${GITHUB_WORKSPACE:-.}" ./scripts/package.sh \ --version "${{ steps.extract-version.outputs.version }}" \ --arch arm64 \ @@ -77,6 +79,7 @@ jobs: - name: Deploy to builds.skaldagent.net run: | + cd "${GITHUB_WORKSPACE:-.}" VERSION="${{ steps.extract-version.outputs.version }}" TARGET="/var/www/builds.skaldagent.net/releases/${VERSION}" mkdir -p "$TARGET" From ef20e4362ff50d1f11d6a314e1ea8559107c98f0 Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Sun, 19 Jul 2026 23:45:23 +0100 Subject: [PATCH 08/42] Debug: print GITHUB_WORKSPACE and file structure --- .gitea/workflows/nightly.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index c38ce0a..d25671f 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -29,6 +29,13 @@ jobs: RUSTFLAGS="-A warnings" cargo build --release --no-default-features --target aarch64-unknown-linux-gnu RUSTFLAGS="-A warnings" cargo build --release --no-default-features -p skald-setup --target aarch64-unknown-linux-gnu + - name: Debug workspace + run: | + echo "PWD: $(pwd)" + echo "GITHUB_WORKSPACE: ${GITHUB_WORKSPACE:-UNSET}" + ls -la "${GITHUB_WORKSPACE:-.}" 2>&1 || true + find "${GITHUB_WORKSPACE:-.}" -maxdepth 2 -name "package.sh" 2>&1 || true + - name: Package amd64 run: | cd "${GITHUB_WORKSPACE:-.}" From aba231b6413368f6b9c78c6dda9b4d4516b4f37b Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Sun, 19 Jul 2026 23:53:44 +0100 Subject: [PATCH 09/42] Fix: gitignore scripts/package.sh e verify-version.sh, tolti da .gitignore --- .gitea/workflows/nightly.yml | 7 ------- .gitignore | 3 ++- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index d25671f..c38ce0a 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -29,13 +29,6 @@ jobs: RUSTFLAGS="-A warnings" cargo build --release --no-default-features --target aarch64-unknown-linux-gnu RUSTFLAGS="-A warnings" cargo build --release --no-default-features -p skald-setup --target aarch64-unknown-linux-gnu - - name: Debug workspace - run: | - echo "PWD: $(pwd)" - echo "GITHUB_WORKSPACE: ${GITHUB_WORKSPACE:-UNSET}" - ls -la "${GITHUB_WORKSPACE:-.}" 2>&1 || true - find "${GITHUB_WORKSPACE:-.}" -maxdepth 2 -name "package.sh" 2>&1 || true - - name: Package amd64 run: | cd "${GITHUB_WORKSPACE:-.}" diff --git a/.gitignore b/.gitignore index 51fcab5..2679507 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ blueprint/ /logs/ /tmp/ /scripts/ +!scripts/package.sh +!scripts/verify-version.sh # Connector folders installed from the marketplace — instance data, like homes/ # and database/, not source. See crates/skald-core/src/mcp/install.rs /connectors/ @@ -50,7 +52,6 @@ node_modules/ # ── Private skills ──────────────────────────────────────────────────────────── skills/.gitignore -scripts/.gitignore # ── Editors & IDEs ──────────────────────────────────────────────────────────── .claude/ From 0e4d38eefcab1343ea4090f3c93b97ad643ba7b4 Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Sun, 19 Jul 2026 23:54:22 +0100 Subject: [PATCH 10/42] =?UTF-8?q?Move=20CI=20scripts=20scripts/=20->=20ci/?= =?UTF-8?q?=20(scripts/=20=C3=A8=20in=20gitignore)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/nightly.yml | 4 +- .gitea/workflows/release.yml | 8 ++-- .gitignore | 2 - ci/package.sh | 92 ++++++++++++++++++++++++++++++++++++ ci/verify-version.sh | 56 ++++++++++++++++++++++ 5 files changed, 154 insertions(+), 8 deletions(-) create mode 100755 ci/package.sh create mode 100755 ci/verify-version.sh diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index c38ce0a..355a0c0 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -32,7 +32,7 @@ jobs: - name: Package amd64 run: | cd "${GITHUB_WORKSPACE:-.}" - ./scripts/package.sh \ + ./ci/package.sh \ --version nightly \ --arch amd64 \ --target-dir /home/dguiducci/.cache/skald-ci/target/release \ @@ -41,7 +41,7 @@ jobs: - name: Package arm64 run: | cd "${GITHUB_WORKSPACE:-.}" - ./scripts/package.sh \ + ./ci/package.sh \ --version nightly \ --arch arm64 \ --target-dir /home/dguiducci/.cache/skald-ci/target/aarch64-unknown-linux-gnu/release \ diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 4add902..1a48f96 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v4 - name: Verify version is new - run: ./scripts/verify-version.sh --builds-dir /var/www/builds.skaldagent.net + run: ./ci/verify-version.sh --builds-dir /var/www/builds.skaldagent.net # ── Push/merge: build, package, and deploy the release ────────────────────── release: @@ -43,7 +43,7 @@ jobs: # Also run verify-version on push to catch any race (belt-and-suspenders) - name: Verify version is new - run: ./scripts/verify-version.sh --builds-dir /var/www/builds.skaldagent.net + run: ./ci/verify-version.sh --builds-dir /var/www/builds.skaldagent.net - name: Build native (linux/amd64) run: | @@ -62,7 +62,7 @@ jobs: - name: Package amd64 run: | cd "${GITHUB_WORKSPACE:-.}" - ./scripts/package.sh \ + ./ci/package.sh \ --version "${{ steps.extract-version.outputs.version }}" \ --arch amd64 \ --target-dir /home/dguiducci/.cache/skald-ci/target/release \ @@ -71,7 +71,7 @@ jobs: - name: Package arm64 run: | cd "${GITHUB_WORKSPACE:-.}" - ./scripts/package.sh \ + ./ci/package.sh \ --version "${{ steps.extract-version.outputs.version }}" \ --arch arm64 \ --target-dir /home/dguiducci/.cache/skald-ci/target/aarch64-unknown-linux-gnu/release \ diff --git a/.gitignore b/.gitignore index 2679507..fd252fa 100644 --- a/.gitignore +++ b/.gitignore @@ -17,8 +17,6 @@ blueprint/ /logs/ /tmp/ /scripts/ -!scripts/package.sh -!scripts/verify-version.sh # Connector folders installed from the marketplace — instance data, like homes/ # and database/, not source. See crates/skald-core/src/mcp/install.rs /connectors/ diff --git a/ci/package.sh b/ci/package.sh new file mode 100755 index 0000000..7a3fd13 --- /dev/null +++ b/ci/package.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env sh +# Package a Skald Circle build into a distributable tarball. +# +# Usage: +# ./scripts/package.sh \ +# --version v0.1.0 \ +# --arch amd64 \ +# --target-dir target/release \ +# --output /tmp/dist +# +# --version Version string, e.g. "v0.1.0" or "nightly" +# --arch Architecture: "amd64" or "arm64" +# --target-dir Path to cargo release output (target/release or +# target/aarch64-unknown-linux-gnu/release) +# --output Directory where the .tar.gz will be written +# +# The tarball contains everything needed to run Skald Circle: +# bin/skald, bin/skald-setup, web/, agents/, skills/, +# default.config.yaml, requirements.txt, run.sh + +set -eu + +cd "$(dirname "$0")/.." + +# ── Parse args ──────────────────────────────────────────────────────────────── +VERSION="" +ARCH="" +TARGET_DIR="" +OUTPUT="" + +while [ $# -gt 0 ]; do + case "$1" in + --version) VERSION="$2"; shift 2 ;; + --arch) ARCH="$2"; shift 2 ;; + --target-dir) TARGET_DIR="$2"; shift 2 ;; + --output) OUTPUT="$2"; shift 2 ;; + *) echo "[package.sh] Unknown option: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$VERSION" ] || [ -z "$ARCH" ] || [ -z "$TARGET_DIR" ] || [ -z "$OUTPUT" ]; then + echo "[package.sh] Missing required argument. See usage." >&2 + exit 1 +fi + +PACKAGE_NAME="skald-circle-${VERSION}-linux-${ARCH}" +STAGING="$(mktemp -d)/${PACKAGE_NAME}" +mkdir -p "$STAGING/bin" + +echo "[package.sh] Packaging $PACKAGE_NAME" +echo "[package.sh] target-dir: $TARGET_DIR" +echo "[package.sh] output: $OUTPUT" + +# ── Verify binaries exist ───────────────────────────────────────────────────── +if [ ! -f "$TARGET_DIR/skald" ]; then + echo "[package.sh] ERROR: skald binary not found at $TARGET_DIR/skald" >&2 + exit 1 +fi +if [ ! -f "$TARGET_DIR/skald-setup" ]; then + echo "[package.sh] ERROR: skald-setup binary not found at $TARGET_DIR/skald-setup" >&2 + exit 1 +fi + +# ── Copy binaries (stripped) ────────────────────────────────────────────────── +cp "$TARGET_DIR/skald" "$STAGING/bin/skald" +cp "$TARGET_DIR/skald-setup" "$STAGING/bin/skald-setup" +strip "$STAGING/bin/skald" "$STAGING/bin/skald-setup" +chmod 755 "$STAGING/bin/skald" "$STAGING/bin/skald-setup" + +# ── Copy runtime assets ─────────────────────────────────────────────────────── +cp -r web "$STAGING/web" +cp -r agents "$STAGING/agents" +cp -r skills "$STAGING/skills" +cp default.config.yaml "$STAGING/default.config.yaml" +cp requirements.txt "$STAGING/requirements.txt" +cp run.sh "$STAGING/run.sh" +chmod 755 "$STAGING/run.sh" + +# ── Create tarball ──────────────────────────────────────────────────────────── +mkdir -p "$OUTPUT" +TARBALL="${OUTPUT}/${PACKAGE_NAME}.tar.gz" + +cd "$(dirname "$STAGING")" +tar czf "$TARBALL" "$PACKAGE_NAME" +cd - > /dev/null + +rm -rf "$(dirname "$STAGING")" + +SHA256="$(sha256sum "$TARBALL" | cut -d' ' -f1)" +echo "[package.sh] ✅ Created $TARBALL" +echo "[package.sh] sha256: $SHA256" +echo "[package.sh] size: $(du -h "$TARBALL" | cut -f1)" diff --git a/ci/verify-version.sh b/ci/verify-version.sh new file mode 100755 index 0000000..e099e8e --- /dev/null +++ b/ci/verify-version.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env sh +# Verify that a Skald Circle release version has not been built yet. +# +# Intended as a required Gitea Actions status check on PRs to the `release` +# branch. Runs in the repo root after checkout. +# +# Usage: +# ./scripts/verify-version.sh \ +# --builds-dir /var/www/builds.skaldagent.net +# +# Exit codes: +# 0 → version is new (or builds-dir doesn't exist yet) → PR may proceed +# 1 → version already built → PR should fail +# +# Reads the version from Cargo.toml in the current directory. + +set -eu + +# ── Parse args ──────────────────────────────────────────────────────────────── +BUILDS_DIR="" + +while [ $# -gt 0 ]; do + case "$1" in + --builds-dir) BUILDS_DIR="$2"; shift 2 ;; + *) echo "[verify-version] Unknown option: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$BUILDS_DIR" ]; then + echo "[verify-version] Missing --builds-dir" >&2 + exit 1 +fi + +# ── Read version from Cargo.toml ────────────────────────────────────────────── +# This is the workspace root's Cargo.toml. +VERSION="$(grep '^version ' Cargo.toml | head -1 | sed 's/version *= *"\(.*\)"/\1/')" + +if [ -z "$VERSION" ]; then + echo "[verify-version] ERROR: Could not read version from Cargo.toml" >&2 + exit 1 +fi + +echo "[verify-version] Version in Cargo.toml: v${VERSION}" + +# ── Check if already built ──────────────────────────────────────────────────── +RELEASE_DIR="${BUILDS_DIR}/releases/v${VERSION}" + +if [ -d "$RELEASE_DIR" ]; then + echo "[verify-version] ❌ Release v${VERSION} already exists at:" + echo "[verify-version] ${RELEASE_DIR}" + echo "[verify-version] Bump the version in Cargo.toml before merging." + exit 1 +fi + +echo "[verify-version] ✅ Release v${VERSION} is new — no conflict." +exit 0 From bee1b4cddb6be847deb2dc03b1f87c620a3b8937 Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Mon, 20 Jul 2026 00:02:25 +0100 Subject: [PATCH 11/42] Fix: absolute path per tarball in package.sh (evita tar in temp dir) --- Cargo.lock | 15 +++++++++++++++ Cargo.toml | 2 ++ ci/package.sh | 2 +- src/main.rs | 1 + 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 31d0979..71e6b20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2944,6 +2944,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "plugin-honcho" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "core-api", + "honcho-client", + "serde_json", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "plugin-mobile-connector" version = "0.1.0" @@ -4168,6 +4182,7 @@ dependencies = [ "notify", "plugin-comfyui", "plugin-elevenlabs", + "plugin-honcho", "plugin-mobile-connector", "plugin-tailscale-remote", "plugin-telegram-bot", diff --git a/Cargo.toml b/Cargo.toml index dd9ecc7..c6dabb9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/plugin-tts-orpheus-3b", "crates/plugin-tts-kokoro", "crates/plugin-elevenlabs", + "crates/plugin-honcho", "crates/skald-relay-common", "crates/skald-relay-server", "crates/skald-relay-client", @@ -84,3 +85,4 @@ plugin-comfyui = { path = "crates/plugin-comfyui" } plugin-tts-orpheus-3b = { path = "crates/plugin-tts-orpheus-3b" } plugin-tts-kokoro = { path = "crates/plugin-tts-kokoro" } plugin-elevenlabs = { path = "crates/plugin-elevenlabs" } +plugin-honcho = { path = "crates/plugin-honcho" } diff --git a/ci/package.sh b/ci/package.sh index 7a3fd13..dee8682 100755 --- a/ci/package.sh +++ b/ci/package.sh @@ -78,7 +78,7 @@ chmod 755 "$STAGING/run.sh" # ── Create tarball ──────────────────────────────────────────────────────────── mkdir -p "$OUTPUT" -TARBALL="${OUTPUT}/${PACKAGE_NAME}.tar.gz" +TARBALL="$(cd "$OUTPUT" && pwd)/${PACKAGE_NAME}.tar.gz" cd "$(dirname "$STAGING")" tar czf "$TARBALL" "$PACKAGE_NAME" diff --git a/src/main.rs b/src/main.rs index dbeb99a..2768697 100644 --- a/src/main.rs +++ b/src/main.rs @@ -153,6 +153,7 @@ fn build_plugins() -> Vec> { Arc::new(plugin_tts_orpheus_3b::OrpheusTtsPlugin::new()), Arc::new(plugin_tts_kokoro::KokoroTtsPlugin::new()), Arc::new(plugin_elevenlabs::ElevenLabsPlugin::new()), + Arc::new(plugin_honcho::HonchoPlugin::new()), ]; #[cfg(feature = "whisper-local")] plugins.push(Arc::new(plugin_transcribe_whisper_local::WhisperLocalPlugin::new())); From cb1b48a15d3c26be609d554753161df3c3eac550 Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Mon, 20 Jul 2026 00:07:33 +0100 Subject: [PATCH 12/42] Honcho: route per-user chat turns onto shared bus, expose to plugins The honcho memory sink needs to observe every user's completed chat turns from one subscription, keyed by ChatEvent.user_id. But each UserContext minted its own per-user ChatEventBus, so a single global subscription saw nothing. - UserContext now publishes onto the shared Runtime.event_bus (the one Skald::subscribe_chat_events reads) instead of a fresh per-user bus. - Expose that bus to plugins as PluginContext.chat_bus (distinct from system_bus, which carries only infra lifecycle events). - plugin-honcho subscribes via ctx.chat_bus. Co-Authored-By: Claude Opus 4.8 --- crates/core-api/src/plugin.rs | 6 ++++++ crates/plugin-honcho/src/lib.rs | 2 +- crates/skald-core/src/plugin/mod.rs | 1 + crates/skald-core/src/skald/user_context.rs | 11 ++++++++++- 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/core-api/src/plugin.rs b/crates/core-api/src/plugin.rs index 8f6fb7f..cb5d871 100644 --- a/crates/core-api/src/plugin.rs +++ b/crates/core-api/src/plugin.rs @@ -5,6 +5,7 @@ use async_trait::async_trait; use serde_json::Value; use tokio::sync::RwLock; +use crate::bus::ChatEventBus; use crate::command::CommandApi; use crate::config_api::ConfigApi; use crate::i18n::I18nApi; @@ -84,6 +85,11 @@ pub struct PluginContext { pub api_provider_registry: Arc, pub location: Arc, pub system_bus: Arc, + /// The single shared chat-turn bus. Every user's completed turns are published + /// here, tagged with `ChatEvent.user_id`. A plugin that builds long-term memory + /// (Honcho) subscribes once and demuxes per user. Distinct from `system_bus`, + /// which carries only infra lifecycle events. + pub chat_bus: Arc, /// Channel-to-session resolver (blueprint §13). Lets channel plugins /// (Telegram, mobile, …) look up an unlocked user's chat hub, approval /// manager and event stream by user id. diff --git a/crates/plugin-honcho/src/lib.rs b/crates/plugin-honcho/src/lib.rs index 9e1177b..bd32991 100644 --- a/crates/plugin-honcho/src/lib.rs +++ b/crates/plugin-honcho/src/lib.rs @@ -892,7 +892,7 @@ impl core_api::plugin::Plugin for HonchoPlugin { self.honcho_memory.activate(Arc::clone(&client), workspace_id.clone(), Arc::clone(&user_config)); let session_map = Arc::clone(&self.honcho_memory.session_map); - let mut rx = ctx.event_bus.subscribe(); + let mut rx = ctx.chat_bus.subscribe(); let cancel = CancellationToken::new(); let cancel_clone = cancel.clone(); let running = Arc::clone(&self.running); diff --git a/crates/skald-core/src/plugin/mod.rs b/crates/skald-core/src/plugin/mod.rs index 423fc8c..b8901a9 100644 --- a/crates/skald-core/src/plugin/mod.rs +++ b/crates/skald-core/src/plugin/mod.rs @@ -187,6 +187,7 @@ impl PluginManager { api_provider_registry: Arc::clone(skald.provider_registry()) as _, location: Arc::clone(skald.location_manager()) as _, system_bus: Arc::clone(skald.system_bus()), + chat_bus: Arc::clone(skald.event_bus()), user_channel: self.skald()? as Arc, user_config: Arc::clone(&self.user_config) as _, i18n: self.i18n(), diff --git a/crates/skald-core/src/skald/user_context.rs b/crates/skald-core/src/skald/user_context.rs index 96d8e31..b3818d1 100644 --- a/crates/skald-core/src/skald/user_context.rs +++ b/crates/skald-core/src/skald/user_context.rs @@ -105,6 +105,11 @@ pub(super) struct UserContextFactory { image_generator_manager: Arc, run_context_manager: Arc, system_bus: Arc, + /// The single shared chat-turn bus. Every per-user `UserContext` publishes its + /// completed turns here (tagged with `user_id`) so a global consumer — the + /// Honcho memory sink — can observe every user's turns from one subscription + /// (`Skald::subscribe_chat_events`) and demux by `ChatEvent.user_id`. + event_bus: Arc, supervisor: Arc, shutdown_token: CancellationToken, max_history_messages: usize, @@ -138,6 +143,7 @@ impl UserContextFactory { image_generator_manager: Arc::clone(&media.image_generator_manager), run_context_manager: Arc::clone(&conversation.run_context_manager), system_bus: Arc::clone(&rt.system_bus), + event_bus: Arc::clone(&rt.event_bus), supervisor: Arc::clone(&rt.supervisor), shutdown_token: rt.shutdown_token.clone(), max_history_messages: config.llm.max_history_messages, @@ -156,7 +162,10 @@ impl UserContextFactory { // A shared swappable cell — a shared-folder membership change is applied in // place while the user is live (§6 remount), not deferred to next login. let fs = SharedFs::new(crate::container::build_user_fs(&self.registry_pool, user_id).await?); - let event_bus = Arc::new(ChatEventBus::new()); + // Shared, not per-user: publish this user's turns onto the one global bus so + // the Honcho sink sees every user from a single subscription (demux by + // `ChatEvent.user_id`). See the field doc on `UserContextFactory::event_bus`. + let event_bus = Arc::clone(&self.event_bus); let (global_tx, _) = broadcast::channel::(512); // Interaction stack, per-user. Approval reads the shared registry rules but From b6128e4053e3114be98c2ed99abb907a9b981d0a Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Mon, 20 Jul 2026 00:14:51 +0100 Subject: [PATCH 13/42] Fix: usa aarch64-linux-gnu-strip per binari arm64 --- ci/package.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ci/package.sh b/ci/package.sh index dee8682..c168b24 100755 --- a/ci/package.sh +++ b/ci/package.sh @@ -62,9 +62,14 @@ if [ ! -f "$TARGET_DIR/skald-setup" ]; then fi # ── Copy binaries (stripped) ────────────────────────────────────────────────── +if [ "$ARCH" = "arm64" ]; then + STRIP="aarch64-linux-gnu-strip" +else + STRIP="strip" +fi cp "$TARGET_DIR/skald" "$STAGING/bin/skald" cp "$TARGET_DIR/skald-setup" "$STAGING/bin/skald-setup" -strip "$STAGING/bin/skald" "$STAGING/bin/skald-setup" +$STRIP "$STAGING/bin/skald" "$STAGING/bin/skald-setup" chmod 755 "$STAGING/bin/skald" "$STAGING/bin/skald-setup" # ── Copy runtime assets ─────────────────────────────────────────────────────── From 44dc67cda0214e8b9ae153b7b4fd8e47e1a745d4 Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Mon, 20 Jul 2026 12:54:56 +0100 Subject: [PATCH 14/42] Setup: utente admin via web, ruoli e run-context con security-group, onboarding install/uninstall script --- .gitea/workflows/release.yml | 5 + CLAUDE.md | 8 +- SKALD.md | 129 ++++++++++---- ci/package.sh | 75 +++++--- crates/core-api/src/events.rs | 8 + crates/skald-core/src/chat_hub/mod.rs | 13 ++ crates/skald-core/src/db/roles.rs | 146 +++++++++++++++- crates/skald-core/src/lib.rs | 1 + crates/skald-core/src/run_context/mod.rs | 104 +++++++++++ crates/skald-core/src/setup/mod.rs | 159 +++++++++++++++++ crates/skald-setup/src/main.rs | 62 +++++-- install-nightly.sh | 195 +++++++++++++++++++++ install.sh | 210 +++++++++++++++++++++++ src/frontend/api/auth.rs | 19 +- src/frontend/api/guard.rs | 7 +- src/frontend/api/mod.rs | 3 + src/frontend/api/roles.rs | 9 + src/frontend/api/run_context.rs | 75 +++++++- src/frontend/api/sessions.rs | 25 ++- src/frontend/api/setup.rs | 55 ++++-- src/frontend/api/ws.rs | 103 +++++++++++ uninstall.sh | 111 ++++++++++++ web/components/copilot.js | 26 +++ web/components/roles-page.js | 39 ++++- web/components/setup-page.js | 37 ++++ web/i18n/en.js | 6 +- web/i18n/fr.js | 6 +- web/i18n/it.js | 6 +- web/lib/chat-session.js | 99 ++++++++++- 29 files changed, 1631 insertions(+), 110 deletions(-) create mode 100644 crates/skald-core/src/setup/mod.rs create mode 100755 install-nightly.sh create mode 100755 install.sh create mode 100644 uninstall.sh diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 1a48f96..2244b81 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -86,3 +86,8 @@ jobs: cp dist/*.tar.gz "$TARGET/" echo "[release] Deployed $VERSION:" ls -lh "$TARGET/" + + - name: Update latest version pointer + run: | + echo "${{ steps.extract-version.outputs.version }}" > /var/www/builds.skaldagent.net/releases/LATEST + echo "[release] Updated releases/LATEST → ${{ steps.extract-version.outputs.version }}" diff --git a/CLAUDE.md b/CLAUDE.md index f7bcad0..79d1f93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ The application core is the `skald-core` crate; the binaries are **shells** arou | ---- | ---- | | `crates/skald-core/` | Storage, identity, crypto, LLM stack, tools, MCP, sessions. Knows nothing about what runs it: no HTTP server and **no concrete plugin crate** — `PluginManager` only ever sees `Arc` from `core-api` | | `skald` (root, `src/`) | The server shell: `main.rs`, the Axum `frontend/`, `config.rs`. Constructs the plugin list and hands it to `Skald::new`. Runs headless as a background daemon under the `run.sh` supervisor | -| `crates/skald-setup/` | Guided first-run setup — a terminal shell over `skald-core`. Creates the first admin via `UserManager::register_user` (asking interface language, whether to encrypt — default yes — and password). The chosen language becomes the instance default (`ui_locale`). A separate binary so the server never links TTY-prompt deps, and so a future GUI installer is a third shell over the same `UserManager`. `run.sh` runs it before the server loop; it prompts only when `users` is empty **and** stdin is a terminal, otherwise a no-op. `--check` reports readiness by exit code (0 done, 1 needed) | +| `crates/skald-setup/` | Guided first-run setup — a terminal shell over `skald-core`. Creates the first admin and seeds the instance through the **shared seam `skald_core::setup::initialize_instance`** (apply the chosen seed profile → `register_user(admin)` → set default locale) — the *same* function the web setup calls, so the two shells can't drift. Asks profile, interface language, whether to encrypt — default yes — and password. A separate binary so the server never links TTY-prompt deps, and so a future GUI installer is a third shell over the same seam. `run.sh` runs it before the server loop; it prompts only when `users` is empty **and** stdin is a terminal, otherwise a no-op. `--check` reports readiness by exit code (0 done, 1 needed) | | `crates/core-api/` | The contracts both sides share: `Plugin`, `Tool`, event buses, provider types | Two rules keep the boundary real, and both are enforced by the compiler: @@ -118,7 +118,7 @@ Schema is greenfield (no migrations, §0), but a purely **additive** column land `system.db` still gets **both** bucket functions — but no longer because the migration is unstarted. It gets the owner schema because it *is* the owner of **shared** memory (`memory_docs`) plus, for now, the globally-scoped `secrets` and the `mcp_events` lifecycle log (`SecretsStore` and the global `McpManager` are built on the system pool and shared by reference into every `UserContext`; the global runtime's *config* now lives in the registry table `mcp_global_servers`, and per-user connector config in each user's owner `mcp_user_servers`). Every *other* owner table is created there but never written to anymore — the global owner-bound managers that would write them (chat/jobs/etc.) are inert (see "Current state"). Fully dropping `create_owner_tables` from `system.db` is blocked on the §4 scope decision for secrets (plus the residual global `mcp_events` log), not on call-site migration. -`users` (`crates/skald-core/src/db/users.rs`) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. `Credentials` is an enum mirroring the table's `CHECK`: an encrypted user carries a **wrapped DEK** (whose AEAD tag *is* the password verifier — hence no `password_hash`); a cleartext user carries an ordinary verifier, or none. `User` is deliberately not `Serialize` and its `Debug` redacts key material — use `User::summary()` for anything leaving the process. `role_id` references `roles(id)` (the `roles` table is now seeded before `users` in `create_registry_tables`). A nullable `locale` column (additive via `ensure_column`) holds the per-user UI language override; role-driven UI conventions live in the free-form `roles.attrs` JSON (e.g. `ui_mode`, see the frontend section) — never new columns per attribute. The admin-managed **directory profile** lives in three more additive columns — `birthdate` (ISO `YYYY-MM-DD`), `sex` (free text), `notes` (admin-authored) — edited only from the Users admin page (`set_directory_fields`; validation — real non-future date, length caps — lives in the `users_mgmt` API, not the db layer) and rendered into agent prompts by the `__USER_PROFILE__` substitution (see above). They are directory metadata written *by* the admin *about* the user, so the registry is their honest home under the §2 threat model. +`users` (`crates/skald-core/src/db/users.rs`) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. `Credentials` is an enum mirroring the table's `CHECK`: an encrypted user carries a **wrapped DEK** (whose AEAD tag *is* the password verifier — hence no `password_hash`); a cleartext user carries an ordinary verifier, or none. `User` is deliberately not `Serialize` and its `Debug` redacts key material — use `User::summary()` for anything leaving the process. `role_id` references `roles(id)` (the `roles` table is now seeded before `users` in `create_registry_tables`). A nullable `locale` column (additive via `ensure_column`) holds the per-user UI language override; role-driven conventions live in the free-form `roles.attrs` JSON — never new columns per attribute — parsed at a **single point** by the typed `db::roles::RoleAttrs` (`ui_mode`, `permission_groups`): `ui_mode` (see the frontend section) plus the role's **security-group set** (`roles.permission_group` = the default group, `attrs.permission_groups` = additional allowed groups; `Role::effective_groups()` = the union, `roles::role_allows_group()` gates it with `admin` short-circuiting to all). See the security-group picker in the frontend section. The admin-managed **directory profile** lives in three more additive columns — `birthdate` (ISO `YYYY-MM-DD`), `sex` (free text), `notes` (admin-authored) — edited only from the Users admin page (`set_directory_fields`; validation — real non-future date, length caps — lives in the `users_mgmt` API, not the db layer) and rendered into agent prompts by the `__USER_PROFILE__` substitution (see above). They are directory metadata written *by* the admin *about* the user, so the registry is their honest home under the §2 threat model. ## Filesystem & containers (blueprint §6) @@ -270,7 +270,9 @@ All extend `LightElement` from `web/lib/base.js` (Lit). `ChatSession` (`web/lib/ **Plugin & backend i18n** — two seams, both keyed the same way. A plugin **page fragment** (served from its own router) localizes client-side: it ships a `web/i18n.js` module (`export default { en, it, fr }`, keys namespaced `plugin..`) and calls `addStrings(dicts)` (in `web/lib/i18n.js`) once at module load to merge into the host's shared `DICTS`, then uses the same `t()`/`I18nMixin` as the app (the fragment imports them from the absolute `/lib/i18n.js` — the *same* module instance the host uses, so `t()` and `locale-changed` are shared; no endpoint, no per-locale fetch — all locales ride in the fragment, so a language switch is instant). Mobile-connector is the reference: `common.js` registers the dict + re-exports `t`, and `MobileBase extends I18nMixin(LitElement)`. **Backend-generated strings** (a plugin's HTTP error/response text, notifications) go through `core_api::i18n`: a plugin declares `Plugin::i18n() -> Vec` (mobile-connector loads them from embedded `i18n/{en,it,fr}.json` via `include_str!`), the `PluginManager` merges every plugin's bundles once at boot into an `I18nCatalog` (`skald_core::i18n`) and injects it as `PluginContext.i18n: Arc`. At request time the handler resolves the caller (`Caller.user_id` from the auth layer) and calls `i18n.for_user(user_id, key, args).await` — which reads `users.locale`, runs it through the same `resolve_locale` chain, and renders `locale → en → key` with `{name}` placeholders. The frontend surfaces these already-translated: `jf()` throws the server's response text verbatim. Front and back keep **separate** tables (UI labels ≠ error strings; overlap is minimal) but share the `plugin..` namespace convention. The mechanism is general (any plugin, and eventually the core, registers the same way); only mobile-connector uses it so far. -**Role-driven interface** (§0.1 — data, not enums): `roles.attrs` JSON may carry `"ui_mode": "simple"`. `/api/auth/me` resolves it (`admin` is always `full`) and the sidebar renders chat + inbox only for simple-mode members; the role editor exposes it as an "Interface" select. Hiding links is never access control — routes stay capability-gated server-side. `MeResponse` also carries `locale`, `default_locale` and `encrypted`. +**Role-driven interface** (§0.1 — data, not enums): `roles.attrs` JSON may carry `"ui_mode": "simple"`. `/api/auth/me` resolves it via `RoleAttrs` (`admin` is always `full`) and the sidebar renders chat + inbox only for simple-mode members; the role editor exposes it as an "Interface" select. Hiding links is never access control — routes stay capability-gated server-side. `MeResponse` also carries `locale`, `default_locale` and `encrypted`. + +**Security-group picker (per-session, runtime, role-gated).** A **security-group** is a *permission bundle only* — a `tool_permission_groups` id, driving tool visibility/approval — **not** a "mode" (no system-context injection; the `RunContext.system_prompt` substrate exists but is unused by the picker). The role carries the user's **allowed set** (default `permission_group` + `attrs.permission_groups`, §0.1); a new non-project session inherits the role's default group (`sessions.rs::create` → `role_default_run_context`). The chat surface switches it **at runtime like the model pill**: `copilot.js` renders a shield pill (hidden when ≤1 group) fed by `GET /api/my/security-groups` (the caller's role set, joined with group names; `admin` → all); selecting one sends the WS control message `{type:"select_security_group", group}` (`chat-session.js::_selectGroup`, twin of `select_client`). The server (`ws.rs::handle_select_security_group_msg`) validates against the role, persists it on `chat_sessions.run_context`, updates the live handler, and **broadcasts `ServerEvent::SecurityGroupSelected`** so every open tab re-syncs (the initial state is sent on WS connect). **Enforcement is server-side** via the shared `run_context::validate_run_context_for_role` (used by both the WS path and the REST `set_session_run_context`): a non-admin may only pick a group in its role's effective set (else 403), and **every other `RunContext` field** (`system_prompt`, `allow_fs_writes`/`allow_fs_reads`, `working_directory`) is **discarded** — closing an fs-escalation hole; `admin` passes through unchanged. The role editor (`roles-page.js`) sets the default group + an allowed-groups checklist (→ `attrs.permission_groups`). | File | Element | Notes | | ---- | ------- | ----- | diff --git a/SKALD.md b/SKALD.md index b74407c..d168e78 100644 --- a/SKALD.md +++ b/SKALD.md @@ -1,56 +1,115 @@ # Skald Circle — SKALD -## Stato attuale +## Current status -Progetto nuova applicazione con agenti e chatbot per aiutare famiglie e piccoli gruppi a collaborare, con chat supervisionato per bambini/persone vulnerabili. +New application with agents and chatbots to help families and small groups collaborate, with supervised chat for children and vulnerable people. -### Icone agenti — completate ✅ +### Agent icons — completed ✅ -Tutti gli 11 agenti hanno ora icone in stile **Vector Paintings** (painterly vector, caldo e family-friendly), generate via ComfyUI: +All 11 agents now have **Vector Paintings** icons (painterly vector, warm and family-friendly), generated via ComfyUI: -| Agente | Animale | Stato | -|--------|---------|-------| -| Main Assistant | 🦊 Volpe | ✅ | -| Project Coordinator | 🦡 Tasso | ✅ | -| Researcher | 🐿️ Scoiattolo | ✅ | -| Generalist | 🦫 Castoro | ✅ | +| Agent | Animal | Status | +|-------|--------|--------| +| Main Assistant | 🦊 Fox | ✅ | +| Project Coordinator | 🦡 Badger | ✅ | +| Researcher | 🐿️ Squirrel | ✅ | +| Generalist | 🦫 Beaver | ✅ | | Code Explorer | 🕵️ Meerkat | ✅ | -| Software Architect | 🏗️ Airone | ✅ | -| Software Engineer | 🔧 Orso | ✅ | -| Spec Writer | 📝 Gufo | ✅ | -| Tech Lead | 👑 Cervo | ✅ | -| TIC | 👁️ Gatto | ✅ | -| Business Analyst | 💼 Gazza | ✅ | +| Software Architect | 🏗️ Heron | ✅ | +| Software Engineer | 🔧 Bear | ✅ | +| Spec Writer | 📝 Owl | ✅ | +| Tech Lead | 👑 Deer | ✅ | +| TIC | 👁️ Cat | ✅ | +| Business Analyst | 💼 Magpie | ✅ | -### Refactoring — completato ✅ +### Refactoring — completed ✅ -- Rimossa dipendenza da Tauri/desktop (`tauri.conf.json`, `src/desktop/`, `icons/`, `docs/desktop.md`, schemi gen/) -- Rimosso `build.rs` (non più necessario) -- Nuovo sistema i18n (core-api + plugin-mobile-connector + web) -- Refactoring sistema di configurazione +- Removed Tauri/desktop dependency (`tauri.conf.json`, `src/desktop/`, `icons/`, `docs/desktop.md`, gen schemas/) +- Removed `build.rs` (no longer needed) +- New i18n system (core-api + plugin-mobile-connector + web) +- Configuration system refactoring ### Auto-build CI/CD ✅ -Build automatica su NiPoGi con Gitea Actions (runner nativo v2.1.0): +Automatic build on NiPoGi with Gitea Actions (native runner v2.1.0): -| Componente | File | Stato | +| Component | File | Status | |---|---|---| -| `scripts/package.sh` | Crea tarball distributivi da binari compilati | ✅ | -| `scripts/verify-version.sh` | Verifica che una release non sia già buildata | ✅ | -| `.gitea/workflows/nightly.yml` | Push su `main` → build amd64+arm64 → nightly/ | ✅ | +| `ci/package.sh` | Creates distribution tarballs from compiled binaries | ✅ | +| `ci/verify-version.sh` | Verifies that a release hasn't been built yet | ✅ | +| `.gitea/workflows/nightly.yml` | Push to `main` → build amd64+arm64 → nightly/ | ✅ | | `.gitea/workflows/release.yml` | PR check `verify-version` + merge → build → releases/v{ver}/ | ✅ | -| **act_runner** nativo su NiPoGi | v2.1.0, host-mode systemd service | ✅ | +| **Native runner** on NiPoGi | v2.1.0, host-mode systemd service, label `linux-amd64` | ✅ | | **Cross toolchain** (arm64) | `gcc-aarch64-linux-gnu` + `rustup target add` | ✅ | -| **Caddy `builds.skaldagent.net`** | Configurato + directory `/var/www/builds.skaldagent.net/` | ✅ | +| **Caddy `builds.skaldagent.net`** | file_server browse (directory listing) | ✅ | | **Route53 `builds.skaldagent.net`** | A record → 145.40.169.107 | ✅ | -| **`install.sh`** | Script one-liner `curl ... | bash` | ⏳ Da creare | +| **CI cache** | Persistent `CARGO_TARGET_DIR` at `/home/dguiducci/.cache/skald-ci/target` | ✅ | +| **`install.sh`** | One-liner script `curl ... | bash` — Linux (systemd) + macOS ARM64 (launchd) | ✅ | +| **`install-nightly.sh`** | One-liner script for nightly builds — same OS support | ✅ | +| **`uninstall.sh`** | Bundled in tarball — stops service/agent, removes everything | ✅ | +| **`releases/LATEST`** | Auto-updated by release workflow to track latest version | ✅ | -### Prossimi passi +### Technical notes -- Creare branch `release` su Gitea con branch protection (PR via UI) -- Testare il workflow con una PR su `release` -- Creare `install.sh` per installazione one-liner +- `scripts/` in `.gitignore` — CI scripts moved to `ci/` (tracked by git) +- Build without `whisper-local` on Linux (`--no-default-features`) +- `aarch64-linux-gnu-strip` for ARM64 binaries +- `actions/checkout@v4` works (native runner has Node.js) +- macOS ARM64 supported via `install.sh` / `install-nightly.sh` (auto-detects OS, uses launchd) -### Future ideas (TODO) +### Next steps -- **One-liner install**: sito web con comando bash da copiare-incollare su macOS/Linux che fa installazione automatica +- Create `release` branch on Gitea with branch protection (PR via UI) +- Test release workflow with a PR +- Build first macOS ARM64 binary on MacBook, upload to `builds.skaldagent.net` + +### macOS support + +**Supported**: macOS ARM64 (Apple Silicon M1+), Intel not supported. + +| Aspect | Status | Notes | +|--------|--------|-------| +| **Install script** (`install.sh`) | ✅ | Auto-detects macOS, uses launchd | +| **Nightly install** (`install-nightly.sh`) | ✅ | Same logic | +| **Uninstall script** (`uninstall.sh`) | ✅ | Handles launchctl | +| **Package script** (`ci/package.sh`) | ✅ | Accepts `--os darwin`, strips best-effort | +| **Binary** | ⏳ Not yet built | Build natively on MacBook, deploy to builds.skaldagent.net | + +#### How to build for macOS (on MacBook) + +```sh +cargo build --release -p skald-setup -p skald # includes whisper +./ci/package.sh --version v0.1.0 --os darwin --arch arm64 \ + --target-dir target/release --output dist/ +``` + +Upload the resulting `dist/skald-circle-v0.1.0-darwin-arm64.tar.gz` to the NiPoGi's `builds.skaldagent.net/releases/v0.1.0/` directory. + +#### Cross-compilation from NiPoGi (research notes 🧪) + +Cross-compiling for `aarch64-apple-darwin` from the NiPoGi using **zig** + **cargo-zigbuild** was attempted but hit blockers. + +| Component | Location | Notes | +|-----------|----------|-------| +| **Zig** | `~/.local/bin/zig` (symlink to `/tmp/zig-linux-x86_64-0.14.0/zig`) | v0.14.0, installed manually | +| **macOS SDK** | `/opt/MacOSX/MacOSX11.3.sdk` | From `phracker/MacOSX-SDKs` (GitHub) | +| **Rust targets** | `aarch64-apple-darwin` | via `rustup target add` | +| **`cargo-zigbuild`** | `~/.cargo/bin/cargo-zigbuild` | v0.23.0 | +| **zig wrapper scripts** | `/tmp/zig-wrap-cxx.sh`, `/tmp/zig-ar-wrap.sh` | Handle OpenSSL/Clang flags + SDK paths | + +**What works:** +- ✅ Rust std compilation for macOS target +- ✅ OpenSSL compilation from source (via wrapper that remaps `--target=` and provides SDK headers) +- ✅ Rust dependency compilation (tree-sitter, sqlx, tokio, etc.) +- ✅ Single-file C programs compile and link correctly + +**What's blocked:** +- ❌ `zig cc` segfaults with `-F` (framework search path) on Linux → can't link against macOS frameworks (CoreFoundation, Security) +- ❌ `zig cc` can't find frameworks without `-F` +- ❌ `libsqlite3-sys` build.rs bug: `is_apple` checks `host.contains("apple") && target.contains("apple")` → forces OpenSSL linkage instead of CommonCrypto on cross-compile (needs upstream fix or `OPENSSL_DIR` workaround) + +**The fix would be:** +1. Upstream fix to `libsqlite3-sys` build.rs (`target.contains("apple")` only) +2. Zig fix for `-F` segfault, or use `ld64` instead of zig's linker + +**Conclusion**: Cross-compilation is fragile. Build natively on MacBook for now. diff --git a/ci/package.sh b/ci/package.sh index c168b24..6547001 100755 --- a/ci/package.sh +++ b/ci/package.sh @@ -2,21 +2,22 @@ # Package a Skald Circle build into a distributable tarball. # # Usage: -# ./scripts/package.sh \ +# ./ci/package.sh \ # --version v0.1.0 \ +# --os linux \ # --arch amd64 \ # --target-dir target/release \ # --output /tmp/dist # -# --version Version string, e.g. "v0.1.0" or "nightly" -# --arch Architecture: "amd64" or "arm64" -# --target-dir Path to cargo release output (target/release or -# target/aarch64-unknown-linux-gnu/release) -# --output Directory where the .tar.gz will be written +# --version Version string, e.g. "v0.1.0" or "nightly" +# --os Target OS: "linux" or "darwin" +# --arch Architecture: "amd64" or "arm64" +# --target-dir Path to cargo release output +# --output Directory where the .tar.gz will be written # -# The tarball contains everything needed to run Skald Circle: +# The tarball contains everything needed to run (or uninstall) Skald Circle: # bin/skald, bin/skald-setup, web/, agents/, skills/, -# default.config.yaml, requirements.txt, run.sh +# default.config.yaml, requirements.txt, run.sh, uninstall.sh set -eu @@ -24,6 +25,7 @@ cd "$(dirname "$0")/.." # ── Parse args ──────────────────────────────────────────────────────────────── VERSION="" +OS="" ARCH="" TARGET_DIR="" OUTPUT="" @@ -31,6 +33,7 @@ OUTPUT="" while [ $# -gt 0 ]; do case "$1" in --version) VERSION="$2"; shift 2 ;; + --os) OS="$2"; shift 2 ;; --arch) ARCH="$2"; shift 2 ;; --target-dir) TARGET_DIR="$2"; shift 2 ;; --output) OUTPUT="$2"; shift 2 ;; @@ -38,18 +41,23 @@ while [ $# -gt 0 ]; do esac done -if [ -z "$VERSION" ] || [ -z "$ARCH" ] || [ -z "$TARGET_DIR" ] || [ -z "$OUTPUT" ]; then +if [ -z "$VERSION" ] || [ -z "$OS" ] || [ -z "$ARCH" ] || [ -z "$TARGET_DIR" ] || [ -z "$OUTPUT" ]; then echo "[package.sh] Missing required argument. See usage." >&2 exit 1 fi -PACKAGE_NAME="skald-circle-${VERSION}-linux-${ARCH}" +case "$OS" in + linux|darwin) ;; + *) echo "[package.sh] Unsupported OS: $OS (use linux or darwin)" >&2; exit 1 ;; +esac + +PACKAGE_NAME="skald-circle-${VERSION}-${OS}-${ARCH}" STAGING="$(mktemp -d)/${PACKAGE_NAME}" mkdir -p "$STAGING/bin" echo "[package.sh] Packaging $PACKAGE_NAME" -echo "[package.sh] target-dir: $TARGET_DIR" -echo "[package.sh] output: $OUTPUT" +echo "[package.sh] target-dir: $TARGET_DIR" +echo "[package.sh] output: $OUTPUT" # ── Verify binaries exist ───────────────────────────────────────────────────── if [ ! -f "$TARGET_DIR/skald" ]; then @@ -61,15 +69,23 @@ if [ ! -f "$TARGET_DIR/skald-setup" ]; then exit 1 fi -# ── Copy binaries (stripped) ────────────────────────────────────────────────── -if [ "$ARCH" = "arm64" ]; then - STRIP="aarch64-linux-gnu-strip" -else - STRIP="strip" -fi +# ── Copy binaries (stripped, best-effort on darwin) ─────────────────────────── cp "$TARGET_DIR/skald" "$STAGING/bin/skald" cp "$TARGET_DIR/skald-setup" "$STAGING/bin/skald-setup" -$STRIP "$STAGING/bin/skald" "$STAGING/bin/skald-setup" + +if [ "$OS" = "darwin" ]; then + # On macOS: strip via xcrun or the system strip (skip if cross-compiled) + if command -v xcrun >/dev/null 2>&1; then + xcrun strip "$STAGING/bin/skald" "$STAGING/bin/skald-setup" 2>/dev/null || true + elif command -v strip >/dev/null 2>&1; then + strip "$STAGING/bin/skald" "$STAGING/bin/skald-setup" 2>/dev/null || true + fi +elif [ "$ARCH" = "arm64" ]; then + STRIP="aarch64-linux-gnu-strip" + $STRIP "$STAGING/bin/skald" "$STAGING/bin/skald-setup" +else + strip "$STAGING/bin/skald" "$STAGING/bin/skald-setup" +fi chmod 755 "$STAGING/bin/skald" "$STAGING/bin/skald-setup" # ── Copy runtime assets ─────────────────────────────────────────────────────── @@ -79,7 +95,8 @@ cp -r skills "$STAGING/skills" cp default.config.yaml "$STAGING/default.config.yaml" cp requirements.txt "$STAGING/requirements.txt" cp run.sh "$STAGING/run.sh" -chmod 755 "$STAGING/run.sh" +cp uninstall.sh "$STAGING/uninstall.sh" +chmod 755 "$STAGING/run.sh" "$STAGING/uninstall.sh" # ── Create tarball ──────────────────────────────────────────────────────────── mkdir -p "$OUTPUT" @@ -91,7 +108,17 @@ cd - > /dev/null rm -rf "$(dirname "$STAGING")" -SHA256="$(sha256sum "$TARBALL" | cut -d' ' -f1)" -echo "[package.sh] ✅ Created $TARBALL" -echo "[package.sh] sha256: $SHA256" -echo "[package.sh] size: $(du -h "$TARBALL" | cut -f1)" +if command -v sha256sum >/dev/null 2>&1; then + SHA256="$(sha256sum "$TARBALL" | cut -d' ' -f1)" + echo "[package.sh] ✅ Created $TARBALL" + echo "[package.sh] sha256: $SHA256" + echo "[package.sh] size: $(du -h "$TARBALL" | cut -f1)" +elif command -v shasum >/dev/null 2>&1; then + SHA256="$(shasum -a 256 "$TARBALL" | cut -d' ' -f1)" + echo "[package.sh] ✅ Created $TARBALL" + echo "[package.sh] sha256: $SHA256" + echo "[package.sh] size: $(du -h "$TARBALL" | cut -f1)" +else + echo "[package.sh] ✅ Created $TARBALL" + echo "[package.sh] size: $(du -h "$TARBALL" | cut -f1)" +fi diff --git a/crates/core-api/src/events.rs b/crates/core-api/src/events.rs index 0ef7537..f4af363 100644 --- a/crates/core-api/src/events.rs +++ b/crates/core-api/src/events.rs @@ -238,6 +238,13 @@ pub enum ServerEvent { ClientSelected { client: String, }, + /// The session security-group (permission group) changed. Broadcast to every + /// client of the source so the chat picker stays in sync — the twin of + /// `ClientSelected` for the model. `group` is the effective group id + /// (`"default"` when cleared). The backend is the single source of truth. + SecurityGroupSelected { + group: String, + }, } impl ServerEvent { @@ -275,6 +282,7 @@ impl ServerEvent { Self::UserMessage { .. } => "user_message", Self::TurnRunning { .. } => "turn_running", Self::ClientSelected { .. } => "client_selected", + Self::SecurityGroupSelected { .. } => "security_group_selected", } } } diff --git a/crates/skald-core/src/chat_hub/mod.rs b/crates/skald-core/src/chat_hub/mod.rs index 3f6c42e..0d061ee 100644 --- a/crates/skald-core/src/chat_hub/mod.rs +++ b/crates/skald-core/src/chat_hub/mod.rs @@ -343,6 +343,19 @@ impl ChatHub { Some(sid) => sid, None => return Ok(()), // no prior session, nothing to resume }; + // Guard against double-driving. A client sends `resume` on connect whenever + // history shows a pending/interrupted tool — including when the turn is still + // live and merely awaiting an approval. Without this check `resume_turn` would + // block on the `processing` lock and, once the approval unblocks the original + // turn and it finishes, run a spurious *second* turn on the just-completed + // conversation. If a turn is already in flight it owns the session and emits + // its own events, so there is nothing to resume — skip. + if let Ok(handler) = self.session_handler(source_id).await { + if handler.is_processing() { + info!(source_id, "ChatHub::resume: turn already in flight — skipping resume"); + return Ok(()); + } + } self.resume_session(session_id).await } diff --git a/crates/skald-core/src/db/roles.rs b/crates/skald-core/src/db/roles.rs index eee28c9..ad858da 100644 --- a/crates/skald-core/src/db/roles.rs +++ b/crates/skald-core/src/db/roles.rs @@ -1,5 +1,5 @@ use anyhow::{Result, bail}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use sqlx::SqlitePool; /// The built-in admin role — immutable from the API. @@ -20,6 +20,81 @@ fn from_raw((id, label, permission_group, attrs, created_at): RawRow) -> Role { Role { id, label, permission_group, attrs, created_at } } +// ── Typed view over `roles.attrs` (§0.1: role attributes live in free-form JSON, +// never per-attribute columns) ──────────────────────────────────────────────── + +/// Interface mode a role opts into. `full` unless the role explicitly chooses the +/// simplified UI; `admin` is resolved to `full` upstream. Values other than the two +/// known ones fall back to `full` (tolerant parse). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum UiMode { + #[default] + Full, + Simple, +} + +impl UiMode { + pub fn as_str(self) -> &'static str { + match self { + UiMode::Full => "full", + UiMode::Simple => "simple", + } + } +} + +/// Typed parse of `roles.attrs`. The **single** place that reads the attrs JSON, so +/// scattered `serde_json::Value.get(...)` calls don't drift. Tolerant: any parse +/// error or missing key yields defaults. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct RoleAttrs { + pub ui_mode: UiMode, + /// Security-groups (`tool_permission_groups` ids) this role may use **in addition** + /// to its default `permission_group`. The default is always implicitly allowed; the + /// effective set is `unique({permission_group} ∪ permission_groups)`. + pub permission_groups: Vec, +} + +impl RoleAttrs { + pub fn from_opt(attrs: &Option) -> RoleAttrs { + attrs + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default() + } +} + +impl Role { + pub fn attrs_parsed(&self) -> RoleAttrs { + RoleAttrs::from_opt(&self.attrs) + } + + /// The security-groups this role may select: its default first, then any extras + /// from `attrs.permission_groups`, deduped. + pub fn effective_groups(&self) -> Vec { + let mut out = vec![self.permission_group.clone()]; + for g in self.attrs_parsed().permission_groups { + if !out.contains(&g) { + out.push(g); + } + } + out + } +} + +/// Whether a role may use `group_id` as its session security-group. `admin` holds +/// every group by construction; a missing role allows nothing. +pub async fn role_allows_group(pool: &SqlitePool, role_id: &str, group_id: &str) -> Result { + if role_id == ADMIN_ROLE_ID { + return Ok(true); + } + match get(pool, role_id).await? { + Some(role) => Ok(role.effective_groups().iter().any(|g| g == group_id)), + None => Ok(false), + } +} + // ── Reads ──────────────────────────────────────────────────────────────────── pub async fn list(pool: &SqlitePool) -> Result> { @@ -122,3 +197,72 @@ pub async fn seed_admin(pool: &SqlitePool) -> Result<()> { .await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp_db(tag: &str) -> String { + let dir = std::env::temp_dir().join(format!("skald-roles-{tag}-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join("system.db").to_str().unwrap().to_string() + } + + fn role(permission_group: &str, attrs: Option<&str>) -> Role { + Role { + id: "member".into(), + label: "Member".into(), + permission_group: permission_group.into(), + attrs: attrs.map(str::to_string), + created_at: String::new(), + } + } + + #[test] + fn role_attrs_are_tolerant() { + // Missing → defaults. + let a = RoleAttrs::from_opt(&None); + assert_eq!(a.ui_mode, UiMode::Full); + assert!(a.permission_groups.is_empty()); + + // Populated. + let a = RoleAttrs::from_opt(&Some( + r#"{"ui_mode":"simple","permission_groups":["ops","research"]}"#.into(), + )); + assert_eq!(a.ui_mode, UiMode::Simple); + assert_eq!(a.permission_groups, vec!["ops", "research"]); + + // Malformed JSON → defaults, never an error. + let a = RoleAttrs::from_opt(&Some("not json".into())); + assert_eq!(a.ui_mode, UiMode::Full); + assert!(a.permission_groups.is_empty()); + } + + #[test] + fn effective_groups_prepends_default_and_dedups() { + let r = role("default", Some(r#"{"permission_groups":["ops","default","research"]}"#)); + assert_eq!(r.effective_groups(), vec!["default", "ops", "research"]); + + // No extras → just the default. + let r = role("kids", None); + assert_eq!(r.effective_groups(), vec!["kids"]); + } + + #[tokio::test] + async fn role_allows_group_admin_member_and_unknown() { + let pool = crate::db::init_system_pool(&tmp_db("allows")).await.unwrap(); + + // admin is seeded by init and allows any group by construction. + assert!(role_allows_group(&pool, ADMIN_ROLE_ID, "anything").await.unwrap()); + + insert(&pool, "member", "Member", "default", Some(r#"{"permission_groups":["ops"]}"#)) + .await + .unwrap(); + assert!(role_allows_group(&pool, "member", "default").await.unwrap()); + assert!(role_allows_group(&pool, "member", "ops").await.unwrap()); + assert!(!role_allows_group(&pool, "member", "research").await.unwrap()); + + // An unknown role allows nothing. + assert!(!role_allows_group(&pool, "ghost", "default").await.unwrap()); + } +} diff --git a/crates/skald-core/src/lib.rs b/crates/skald-core/src/lib.rs index 57d9830..6c20a0f 100644 --- a/crates/skald-core/src/lib.rs +++ b/crates/skald-core/src/lib.rs @@ -41,6 +41,7 @@ pub mod run_context; pub mod secrets; pub mod service_manager; pub mod session; +pub mod setup; pub mod tic; pub mod tool_catalog; pub mod tool_discovery; diff --git a/crates/skald-core/src/run_context/mod.rs b/crates/skald-core/src/run_context/mod.rs index ec8b3d0..f75ef01 100644 --- a/crates/skald-core/src/run_context/mod.rs +++ b/crates/skald-core/src/run_context/mod.rs @@ -100,6 +100,53 @@ impl RunContext { } } +/// Outcome of validating a client-supplied [`RunContext`] against the caller's role. +pub enum RunContextDecision { + /// Apply this (possibly sanitized) run-context to the session. + Apply(Option), + /// The requested security-group is not in the role's allowed set (→ 403); the + /// string is the offending group id. + Forbidden(String), +} + +/// Gate a client-supplied run-context by the caller's role, closing two holes at +/// once (§0.1 — enforce server-side, never trust the client): +/// +/// - **Group governance**: a non-admin may only select a security-group in its +/// role's effective set ([`crate::db::roles::role_allows_group`]); anything else +/// is [`RunContextDecision::Forbidden`]. +/// - **fs escalation**: for a non-admin every other `RunContext` field +/// (`system_prompt`, `allow_fs_writes`/`allow_fs_reads`, `working_directory`) is +/// **discarded** — the client can set the permission group, nothing more. A rich +/// run-context (a project's) is resolved server-side, never through this path. +/// +/// `admin` is trusted and passes through unchanged. `None` (clear) is always +/// allowed and falls back to the role's default group at session build. +pub async fn validate_run_context_for_role( + registry_pool: &SqlitePool, + role_id: &str, + incoming: Option, +) -> Result { + if role_id == crate::db::roles::ADMIN_ROLE_ID { + return Ok(RunContextDecision::Apply(incoming)); + } + let Some(rc) = incoming else { + return Ok(RunContextDecision::Apply(None)); + }; + match rc.tool_group_id() { + // A non-admin that names no group is treated as a clear (→ default group). + None => Ok(RunContextDecision::Apply(None)), + Some(group) => { + if crate::db::roles::role_allows_group(registry_pool, role_id, group).await? { + let group = group.to_string(); + Ok(RunContextDecision::Apply(Some(RunContext::with_security_group(Some(group))))) + } else { + Ok(RunContextDecision::Forbidden(group.to_string())) + } + } + } +} + pub struct RunContextManager { db: Arc, approval: Arc, @@ -373,4 +420,61 @@ mod tests { std::fs::remove_dir_all(&wd).ok(); } + + #[tokio::test] + async fn validate_admin_passes_through_untouched() { + let path = unique_tmp().join("system.db"); + let pool = crate::db::init_system_pool(path.to_str().unwrap()).await.unwrap(); + let rc = RunContext { + security_group: Some("ops".into()), + allow_fs_writes: vec!["/etc".into()], + ..Default::default() + }; + match validate_run_context_for_role(&pool, "admin", Some(rc)).await.unwrap() { + RunContextDecision::Apply(Some(got)) => { + assert_eq!(got.tool_group_id(), Some("ops")); + assert_eq!(got.allow_fs_writes, vec!["/etc".to_string()]); + } + _ => panic!("admin must pass through unchanged"), + } + } + + #[tokio::test] + async fn validate_non_admin_gates_group_and_strips_fs() { + let path = unique_tmp().join("system.db"); + let pool = crate::db::init_system_pool(path.to_str().unwrap()).await.unwrap(); + crate::db::roles::insert(&pool, "member", "Member", "default", + Some(r#"{"permission_groups":["ops"]}"#)).await.unwrap(); + + // Allowed group: kept, but every other field is discarded (fs hardening). + let rc = RunContext { + security_group: Some("ops".into()), + allow_fs_writes: vec!["/etc".into()], + system_prompt: vec!["ignore me".into()], + ..Default::default() + }; + match validate_run_context_for_role(&pool, "member", Some(rc)).await.unwrap() { + RunContextDecision::Apply(Some(got)) => { + assert_eq!(got.tool_group_id(), Some("ops")); + assert!(got.allow_fs_writes.is_empty()); + assert!(got.system_prompt.is_empty()); + } + _ => panic!("an allowed group must apply, sanitized"), + } + + // A group outside the role's set is refused. + let rc = RunContext { security_group: Some("secret".into()), ..Default::default() }; + match validate_run_context_for_role(&pool, "member", Some(rc)).await.unwrap() { + RunContextDecision::Forbidden(g) => assert_eq!(g, "secret"), + _ => panic!("a group outside the set must be forbidden"), + } + + // Clearing is always allowed (falls back to the role default at build time). + match validate_run_context_for_role(&pool, "member", None).await.unwrap() { + RunContextDecision::Apply(None) => {} + _ => panic!("clear must be allowed"), + } + + std::fs::remove_dir_all(path.parent().unwrap()).ok(); + } } diff --git a/crates/skald-core/src/setup/mod.rs b/crates/skald-core/src/setup/mod.rs new file mode 100644 index 0000000..fcf5c65 --- /dev/null +++ b/crates/skald-core/src/setup/mod.rs @@ -0,0 +1,159 @@ +//! First-run instance initialization — the seam both setup shells share. +//! +//! `skald-setup` (the terminal wizard) and the web setup endpoint both need to do +//! the same thing exactly once: seed the instance's roles from a chosen **seed +//! profile** and create the first admin. Keeping that here — rather than duplicated +//! in each shell — is what stops the two paths from drifting apart. +//! +//! A [`SeedProfile`] is the neutral primitive (§0.1); the domain flavour ("Family", +//! "Office", …) lives only in the profile's seed data — labels and role presets, +//! never in the engine. One profile ships today; adding another is data, not code. + +use anyhow::{Result, anyhow}; +use sqlx::SqlitePool; + +use crate::db::{self, roles::ADMIN_ROLE_ID}; +use crate::users::UserManager; + +/// One role a profile seeds. `attrs` is the `roles.attrs` JSON (§0.1) — `ui_mode`, +/// allowed security-groups, and future role attributes. +pub struct RoleSeed { + pub id: &'static str, + pub label: &'static str, + pub permission_group: &'static str, + pub attrs: Option<&'static str>, +} + +/// A named preset of roles the admin picks at first-run. Neutral mechanism; the +/// domain lives in the data. +pub struct SeedProfile { + pub id: &'static str, + pub label: &'static str, + pub roles: Vec, +} + +/// The profiles offered by the setup picker. `admin` is seeded universally at +/// table-creation (an FK invariant, `db::roles::seed_admin`), so a profile only +/// adds its **domain** roles. Ship one now; `office` / `family-no-kids` are just +/// more entries here — no engine change. +pub fn seed_profiles() -> Vec { + vec![SeedProfile { + id: "family", + label: "Family", + roles: vec![ + RoleSeed { + id: "member", + label: "Member", + permission_group: "default", + attrs: Some(r#"{"ui_mode":"full"}"#), + }, + RoleSeed { + id: "children", + label: "Children", + permission_group: "default", + attrs: Some(r#"{"ui_mode":"simple"}"#), + }, + ], + }] +} + +/// Look up a profile by id. +pub fn seed_profile(id: &str) -> Option { + seed_profiles().into_iter().find(|p| p.id == id) +} + +/// Seed a profile's roles (+ their default self-service capabilities) into the +/// registry. Idempotent: an existing role id is left untouched, so a re-run never +/// clobbers an admin-edited role. Runs at first-run, after every registry table +/// exists — so `role_capabilities` is present (no ordering hazard). +pub async fn apply_seed_profile(pool: &SqlitePool, profile_id: &str) -> Result<()> { + let profile = + seed_profile(profile_id).ok_or_else(|| anyhow!("unknown seed profile: {profile_id}"))?; + for role in &profile.roles { + if db::roles::get(pool, role.id).await?.is_some() { + continue; // already present — leave it as the admin left it + } + db::roles::insert(pool, role.id, role.label, role.permission_group, role.attrs).await?; + // The standard self-service capabilities, exactly as `roles::create` grants + // them through the API (§14). + db::role_capabilities::seed_defaults(pool, role.id).await?; + } + Ok(()) +} + +/// Everything a shell needs to know about the first admin. +pub struct FirstAdmin<'a> { + pub username: &'a str, + pub display_name: Option<&'a str>, + pub password: Option<&'a str>, + pub encrypted: bool, + /// Interface language → the instance default (`ui_locale`). `None` leaves the + /// registry default (English) in place. + pub locale: Option<&'a str>, +} + +/// First-run initialization, shared by both setup shells: apply the chosen seed +/// profile, create the admin, set the instance default locale. Returns the new +/// admin's user id. +/// +/// The default-locale write goes straight to `db::config` (no system bus): at +/// first-run nothing is listening, so both shells converge on the same path. +pub async fn initialize_instance( + users: &UserManager, + pool: &SqlitePool, + profile_id: &str, + admin: FirstAdmin<'_>, +) -> Result { + apply_seed_profile(pool, profile_id).await?; + + let id = users + .register_user( + admin.username, + admin.display_name, + ADMIN_ROLE_ID, + admin.password, + admin.encrypted, + ) + .await?; + + if let Some(locale) = admin.locale { + crate::i18n::set_default_locale(pool, locale).await?; + } + + Ok(id) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::roles::UiMode; + + fn tmp_db(tag: &str) -> String { + let dir = std::env::temp_dir().join(format!("skald-setup-{tag}-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join("system.db").to_str().unwrap().to_string() + } + + #[tokio::test] + async fn family_profile_seeds_roles_and_caps_idempotently() { + let pool = crate::db::init_system_pool(&tmp_db("family")).await.unwrap(); + apply_seed_profile(&pool, "family").await.unwrap(); + apply_seed_profile(&pool, "family").await.unwrap(); // idempotent + + let member = db::roles::get(&pool, "member").await.unwrap().unwrap(); + assert_eq!(member.attrs_parsed().ui_mode, UiMode::Full); + let children = db::roles::get(&pool, "children").await.unwrap().unwrap(); + assert_eq!(children.attrs_parsed().ui_mode, UiMode::Simple); + + // The standard self-service capabilities were granted to a seeded role. + assert!(db::role_capabilities::has( + &pool, "member", db::role_capabilities::REGISTER_REMOTE, + ).await.unwrap()); + } + + #[tokio::test] + async fn unknown_profile_is_an_error() { + let pool = crate::db::init_system_pool(&tmp_db("unknown")).await.unwrap(); + assert!(apply_seed_profile(&pool, "does-not-exist").await.is_err()); + } +} diff --git a/crates/skald-setup/src/main.rs b/crates/skald-setup/src/main.rs index 6c0bb2c..e3c93bc 100644 --- a/crates/skald-setup/src/main.rs +++ b/crates/skald-setup/src/main.rs @@ -25,13 +25,9 @@ use std::io::{self, IsTerminal, Write}; use anyhow::{Context, Result}; use skald_core::db::{self, SYSTEM_DB_PATH}; +use skald_core::setup::{self, FirstAdmin}; use skald_core::users::UserManager; -/// The role id given to the first user. There is no `roles` table yet, and -/// `users.role_id` has no foreign key, so this is a plain string for now — the -/// seeded `admin` preset (blueprint §12) will adopt it later without a migration. -const ADMIN_ROLE: &str = "admin"; - /// SQLCipher's per-user privacy is only as strong as the password's entropy /// times the KDF cost (§5.1). The KDF is fixed; this is the floor we put under /// the entropy. Not a substitute for a real strength meter — a deliberate, @@ -139,6 +135,7 @@ async fn step_first_user(users: &UserManager, pool: &sqlx::SqlitePool, has_admin println!("\nWelcome to Skald. Let's create the first user (the admin).\n"); + let profile = prompt_profile()?; let username = prompt_username()?; let display_name = prompt_line("Display name (optional): ")?; let display_name = display_name.trim(); @@ -148,16 +145,23 @@ async fn step_first_user(users: &UserManager, pool: &sqlx::SqlitePool, has_admin let encrypt = prompt_encrypt()?; let password = prompt_new_password()?; - let id = users - .register_user(&username, display_name, ADMIN_ROLE, Some(&password), encrypt) - .await - .context("creating the admin user")?; - - // The first-run language choice is instance-wide: the registry config - // default every user follows until they override it on their profile. - skald_core::i18n::set_default_locale(pool, &locale) - .await - .context("saving the default language")?; + // The shared seam both setup shells call: seed the chosen profile's roles, + // create the admin, set the instance default language — one implementation, so + // the terminal and web wizards can never drift apart. + let id = setup::initialize_instance( + users, + pool, + &profile, + FirstAdmin { + username: &username, + display_name, + password: Some(&password), + encrypted: encrypt, + locale: Some(&locale), + }, + ) + .await + .context("initializing the instance")?; println!("\n✓ Admin user '{username}' created (id {id})."); if encrypt { @@ -169,6 +173,34 @@ async fn step_first_user(users: &UserManager, pool: &sqlx::SqlitePool, has_admin // ── Prompts ──────────────────────────────────────────────────────────────── +/// Which seed profile to provision. The profile decides which domain roles the +/// instance starts with (`family` → member + children). With a single profile +/// there is nothing to choose, so it is selected silently. +fn prompt_profile() -> Result { + let profiles = setup::seed_profiles(); + if profiles.len() <= 1 { + return Ok(profiles + .into_iter() + .next() + .map(|p| p.id.to_string()) + .unwrap_or_else(|| "family".to_string())); + } + println!("What kind of instance is this?"); + for (i, p) in profiles.iter().enumerate() { + println!(" {}) {}", i + 1, p.label); + } + loop { + let line = prompt_line("Choose [1]: ")?; + let line = line.trim(); + let choice = if line.is_empty() { 1 } else { line.parse::().unwrap_or(0) }; + if (1..=profiles.len()).contains(&choice) { + println!(); + return Ok(profiles[choice - 1].id.to_string()); + } + println!(" Please enter a number between 1 and {}.", profiles.len()); + } +} + fn prompt_username() -> Result { loop { let name = prompt_line("Username: ")?; diff --git a/install-nightly.sh b/install-nightly.sh new file mode 100755 index 0000000..4ae8562 --- /dev/null +++ b/install-nightly.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env sh +# install-nightly.sh — install the latest nightly build of Skald Circle +# +# Usage: +# curl -fsSL https://builds.skaldagent.net/install-nightly.sh | bash +# +# Supports Linux (systemd) and macOS ARM64 (launchd). +# Default install dir: ~/.local/share/skald-circle (override with SKALD_DIR). +# +# Inspired by: https://hermes-agent.nousresearch.com/install.sh + +set -eu + +# ── User overrides ──────────────────────────────────────────────────────────── +INSTALL_DIR="${SKALD_DIR:-$HOME/.local/share/skald-circle}" + +# ── Colours (if terminal) ───────────────────────────────────────────────────── +if [ -t 1 ]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + BOLD='\033[1m' + NC='\033[0m' +else + RED=''; GREEN=''; YELLOW=''; BOLD=''; NC='' +fi + +info() { printf "${GREEN}%s${NC}\n" "$*"; } +warn() { printf "${YELLOW}⚠ %s${NC}\n" "$*"; } +err() { printf "${RED}✖ %s${NC}\n" "$*"; } +header(){ printf "\n${BOLD}%s${NC}\n" "$*"; } + +# ── Platform detection ──────────────────────────────────────────────────────── +OS="$(uname -s)" +ARCH="$(uname -m)" + +case "$OS" in + Linux) OS="linux" ;; + Darwin) OS="darwin" ;; + *) err "Unsupported OS: $OS"; exit 1 ;; +esac + +case "$ARCH" in + x86_64) + ARCH="amd64" + if [ "$OS" = "darwin" ]; then + err "Intel Macs are not supported. Apple Silicon (M1+) only." + exit 1 + fi + ;; + aarch64|arm64) + ARCH="arm64" + ;; + *) err "Unsupported architecture: $ARCH"; exit 1 ;; +esac + +# ── Dependency checks ───────────────────────────────────────────────────────── +command -v curl >/dev/null 2>&1 || { err "curl is required but not installed."; exit 1; } + +if [ "$OS" = "linux" ]; then + command -v systemctl >/dev/null 2>&1 || { warn "systemd not found — service will not be installed automatically."; NOSYSTEMD=1; } +elif [ "$OS" = "darwin" ]; then + command -v launchctl >/dev/null 2>&1 || { err "launchctl not found."; exit 1; } +fi + +# ── Download & extract ──────────────────────────────────────────────────────── +BASE_URL="https://builds.skaldagent.net" +TARBALL_URL="${BASE_URL}/nightly/skald-circle-nightly-${OS}-${ARCH}.tar.gz" + +header "📦 Skald Circle — Nightly Installer" +echo "" +echo " Platform : ${OS}/${ARCH}" +echo " Install dir : ${INSTALL_DIR}" +echo " Download : ${TARBALL_URL}" +echo "" + +info "↓ Downloading Skald Circle nightly …" +mkdir -p "$INSTALL_DIR" +curl -fsSL "$TARBALL_URL" | tar xz -C "$INSTALL_DIR" --strip-components=1 + +if [ ! -x "$INSTALL_DIR/bin/skald" ]; then + err "Download or extraction failed — skald binary not found." + exit 1 +fi + +info "✔ Extracted to ${INSTALL_DIR}" + +# ── Python venv (best-effort) ───────────────────────────────────────────────── +info "🔧 Setting up Python virtual environment …" +"$INSTALL_DIR/run.sh" >/dev/null 2>&1 || true + +# ── First-run setup (interactive) ───────────────────────────────────────────── +if [ -t 0 ] && [ -x "$INSTALL_DIR/bin/skald-setup" ]; then + header "⚙️ First-time setup" + echo " You will be asked to configure your LLM provider and create an admin user." + echo "" + "$INSTALL_DIR/bin/skald-setup" + echo "" +fi + +# ── Install daemon ──────────────────────────────────────────────────────────── +if [ "$OS" = "linux" ] && [ -z "${NOSYSTEMD:-}" ]; then + header "⚡ Installing systemd user service …" + + mkdir -p "$HOME/.config/systemd/user" + + cat > "$HOME/.config/systemd/user/skald-circle.service" <<- SERVICE +[Unit] +Description=Skald Circle (nightly) +Documentation=https://skaldagent.net +After=network.target + +[Service] +Type=simple +ExecStart=${INSTALL_DIR}/run.sh +WorkingDirectory=${INSTALL_DIR} +Restart=on-failure +RestartSec=5 +Environment=SKALD_BIN=${INSTALL_DIR}/bin/skald +Environment=SKALD_SETUP_BIN=${INSTALL_DIR}/bin/skald-setup + +[Install] +WantedBy=default.target +SERVICE + + systemctl --user daemon-reload + systemctl --user enable --now skald-circle.service + + info "✔ Service installed and started" + echo "" + echo " Status: systemctl --user status skald-circle" + echo " Logs: journalctl --user -u skald-circle -f" + +elif [ "$OS" = "darwin" ]; then + header "⚡ Installing launchd agent …" + + mkdir -p "$HOME/Library/LaunchAgents" "$INSTALL_DIR/logs" + + PLIST="$HOME/Library/LaunchAgents/com.skald.circle.plist" + + cat > "$PLIST" <<- PLIST + + + + + Label + com.skald.circle + + ProgramArguments + + ${INSTALL_DIR}/run.sh + + + WorkingDirectory + ${INSTALL_DIR} + + RunAtLoad + + KeepAlive + + + StandardOutPath + ${INSTALL_DIR}/logs/stdout.log + StandardErrorPath + ${INSTALL_DIR}/logs/stderr.log + + EnvironmentVariables + + SKALD_BIN + ${INSTALL_DIR}/bin/skald + SKALD_SETUP_BIN + ${INSTALL_DIR}/bin/skald-setup + + + +PLIST + + launchctl load "$PLIST" + + info "✔ Agent installed and started" + echo "" + echo " Status: launchctl list com.skald.circle" + echo " Logs: tail -f ${INSTALL_DIR}/logs/stdout.log" + +elif [ -n "${NOSYSTEMD:-}" ]; then + warn "systemd not available — start manually: ${INSTALL_DIR}/run.sh" +fi + +echo "" +info "✅ Skald Circle (nightly) installed successfully!" +echo "" +echo " ${INSTALL_DIR}/run.sh" +echo " ${INSTALL_DIR}/bin/skald" +echo " ${INSTALL_DIR}/bin/skald-setup" diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..7a9c4cc --- /dev/null +++ b/install.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env sh +# install.sh — install the latest release of Skald Circle +# +# Usage: +# curl -fsSL https://builds.skaldagent.net/install.sh | bash +# +# Supports Linux (systemd) and macOS ARM64 (launchd). +# Default install dir: ~/.local/share/skald-circle (override with SKALD_DIR). +# +# Inspired by: https://hermes-agent.nousresearch.com/install.sh + +set -eu + +# ── User overrides ──────────────────────────────────────────────────────────── +INSTALL_DIR="${SKALD_DIR:-$HOME/.local/share/skald-circle}" + +# ── Colours (if terminal) ───────────────────────────────────────────────────── +if [ -t 1 ]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + BOLD='\033[1m' + NC='\033[0m' +else + RED=''; GREEN=''; YELLOW=''; BOLD=''; NC='' +fi + +info() { printf "${GREEN}%s${NC}\n" "$*"; } +warn() { printf "${YELLOW}⚠ %s${NC}\n" "$*"; } +err() { printf "${RED}✖ %s${NC}\n" "$*"; } +header(){ printf "\n${BOLD}%s${NC}\n" "$*"; } + +# ── Platform detection ──────────────────────────────────────────────────────── +OS="$(uname -s)" +ARCH="$(uname -m)" + +case "$OS" in + Linux) OS="linux" ;; + Darwin) OS="darwin" ;; + *) err "Unsupported OS: $OS"; exit 1 ;; +esac + +case "$ARCH" in + x86_64) + ARCH="amd64" + if [ "$OS" = "darwin" ]; then + err "Intel Macs are not supported. Apple Silicon (M1+) only." + exit 1 + fi + ;; + aarch64|arm64) + ARCH="arm64" + ;; + *) err "Unsupported architecture: $ARCH"; exit 1 ;; +esac + +# ── Dependency checks ───────────────────────────────────────────────────────── +command -v curl >/dev/null 2>&1 || { err "curl is required but not installed."; exit 1; } + +if [ "$OS" = "linux" ]; then + command -v systemctl >/dev/null 2>&1 || { warn "systemd not found — service will not be installed automatically."; NOSYSTEMD=1; } +elif [ "$OS" = "darwin" ]; then + command -v launchctl >/dev/null 2>&1 || { err "launchctl not found."; exit 1; } +fi + +# ── Fetch latest version ────────────────────────────────────────────────────── +BASE_URL="https://builds.skaldagent.net" +LATEST_URL="${BASE_URL}/releases/LATEST" + +header "📦 Skald Circle — Installer" +echo "" + +info "🔍 Looking up latest release …" +VERSION="$(curl -fsSL "$LATEST_URL" | head -1 | tr -d '[:space:]')" + +if [ -z "$VERSION" ]; then + err "Could not determine latest release version." + err "Check ${LATEST_URL} or try install-nightly.sh for the latest build." + exit 1 +fi + +TARBALL_URL="${BASE_URL}/releases/${VERSION}/skald-circle-${VERSION}-${OS}-${ARCH}.tar.gz" + +echo "" +echo " Version : ${VERSION}" +echo " Platform : ${OS}/${ARCH}" +echo " Install dir : ${INSTALL_DIR}" +echo " Download : ${TARBALL_URL}" +echo "" + +# ── Download & extract ──────────────────────────────────────────────────────── +info "↓ Downloading Skald Circle ${VERSION} …" +mkdir -p "$INSTALL_DIR" +curl -fsSL "$TARBALL_URL" | tar xz -C "$INSTALL_DIR" --strip-components=1 + +if [ ! -x "$INSTALL_DIR/bin/skald" ]; then + err "Download or extraction failed — skald binary not found." + exit 1 +fi + +info "✔ Extracted to ${INSTALL_DIR}" + +# ── Python venv (best-effort) ───────────────────────────────────────────────── +info "🔧 Setting up Python virtual environment …" +"$INSTALL_DIR/run.sh" >/dev/null 2>&1 || true + +# ── First-run setup (interactive) ───────────────────────────────────────────── +if [ -t 0 ] && [ -x "$INSTALL_DIR/bin/skald-setup" ]; then + header "⚙️ First-time setup" + echo " You will be asked to configure your LLM provider and create an admin user." + echo "" + "$INSTALL_DIR/bin/skald-setup" + echo "" +fi + +# ── Install daemon ──────────────────────────────────────────────────────────── +if [ "$OS" = "linux" ] && [ -z "${NOSYSTEMD:-}" ]; then + header "⚡ Installing systemd user service …" + + mkdir -p "$HOME/.config/systemd/user" + + cat > "$HOME/.config/systemd/user/skald-circle.service" <<- SERVICE +[Unit] +Description=Skald Circle (release ${VERSION}) +Documentation=https://skaldagent.net +After=network.target + +[Service] +Type=simple +ExecStart=${INSTALL_DIR}/run.sh +WorkingDirectory=${INSTALL_DIR} +Restart=on-failure +RestartSec=5 +Environment=SKALD_BIN=${INSTALL_DIR}/bin/skald +Environment=SKALD_SETUP_BIN=${INSTALL_DIR}/bin/skald-setup + +[Install] +WantedBy=default.target +SERVICE + + systemctl --user daemon-reload + systemctl --user enable --now skald-circle.service + + info "✔ Service installed and started" + echo "" + echo " Status: systemctl --user status skald-circle" + echo " Logs: journalctl --user -u skald-circle -f" + +elif [ "$OS" = "darwin" ]; then + header "⚡ Installing launchd agent …" + + mkdir -p "$HOME/Library/LaunchAgents" "$INSTALL_DIR/logs" + + PLIST="$HOME/Library/LaunchAgents/com.skald.circle.plist" + + cat > "$PLIST" <<- PLIST + + + + + Label + com.skald.circle + + ProgramArguments + + ${INSTALL_DIR}/run.sh + + + WorkingDirectory + ${INSTALL_DIR} + + RunAtLoad + + KeepAlive + + + StandardOutPath + ${INSTALL_DIR}/logs/stdout.log + StandardErrorPath + ${INSTALL_DIR}/logs/stderr.log + + EnvironmentVariables + + SKALD_BIN + ${INSTALL_DIR}/bin/skald + SKALD_SETUP_BIN + ${INSTALL_DIR}/bin/skald-setup + + + +PLIST + + launchctl load "$PLIST" + + info "✔ Agent installed and started" + echo "" + echo " Status: launchctl list com.skald.circle" + echo " Logs: tail -f ${INSTALL_DIR}/logs/stdout.log" + +elif [ -n "${NOSYSTEMD:-}" ]; then + warn "systemd not available — start manually: ${INSTALL_DIR}/run.sh" +fi + +echo "" +info "✅ Skald Circle ${VERSION} installed successfully!" +echo "" +echo " ${INSTALL_DIR}/run.sh" +echo " ${INSTALL_DIR}/bin/skald" +echo " ${INSTALL_DIR}/bin/skald-setup" diff --git a/src/frontend/api/auth.rs b/src/frontend/api/auth.rs index c3c0543..64ca7bb 100644 --- a/src/frontend/api/auth.rs +++ b/src/frontend/api/auth.rs @@ -109,22 +109,21 @@ pub async fn me( .into_response()) } -/// Reads `roles.attrs.ui_mode` for the given role. Any error or missing key -/// resolves to "full" — the simplified UI is strictly opt-in. +/// Reads `roles.attrs.ui_mode` for the given role via the typed [`RoleAttrs`] +/// (the single attrs parse point). Any error or missing key resolves to "full" — +/// the simplified UI is strictly opt-in, and `admin` is always "full". async fn resolve_ui_mode(skald: &Skald, role_id: &str) -> String { - if role_id == skald_core::db::roles::ADMIN_ROLE_ID { + use skald_core::db::roles; + if role_id == roles::ADMIN_ROLE_ID { return "full".into(); } - let attrs = skald_core::db::roles::get(skald.db(), role_id) + let ui_mode = roles::get(skald.db(), role_id) .await .ok() .flatten() - .and_then(|r| r.attrs); - attrs - .and_then(|a| serde_json::from_str::(&a).ok()) - .and_then(|v| v.get("ui_mode")?.as_str().map(str::to_owned)) - .filter(|m| m == "simple" || m == "full") - .unwrap_or_else(|| "full".into()) + .map(|r| r.attrs_parsed().ui_mode) + .unwrap_or_default(); + ui_mode.as_str().into() } // ── POST /api/auth/logout ──────────────────────────────────────────────────── diff --git a/src/frontend/api/guard.rs b/src/frontend/api/guard.rs index ebf5dc8..d0a2082 100644 --- a/src/frontend/api/guard.rs +++ b/src/frontend/api/guard.rs @@ -47,7 +47,12 @@ fn is_public(path: &str) -> bool { let p = path.strip_prefix("/api").unwrap_or(path); matches!( p, - "/auth/login" | "/auth/logout" | "/auth/me" | "/setup/status" | "/setup/user" + "/auth/login" + | "/auth/logout" + | "/auth/me" + | "/setup/status" + | "/setup/user" + | "/setup/profiles" ) } diff --git a/src/frontend/api/mod.rs b/src/frontend/api/mod.rs index 0150c20..31cdac1 100644 --- a/src/frontend/api/mod.rs +++ b/src/frontend/api/mod.rs @@ -54,6 +54,7 @@ pub fn router() -> Router> { .route("/sessions", get(sessions::list_sessions).post(sessions::create)) // First-run setup .route("/setup/status", get(setup::status)) + .route("/setup/profiles", get(setup::profiles)) .route("/setup/user", post(setup::create_user)) // Auth .route("/auth/login", post(auth::login)) @@ -129,6 +130,8 @@ pub fn router() -> Router> { .route("/tool-permission-groups", get(run_context::list_groups).post(run_context::create_group)) .route("/tool-permission-groups/{id}", put(run_context::update_group).delete(run_context::delete_group)) .route("/tool-permission-groups/{id}/duplicate", post(run_context::duplicate_group)) + // The caller's own selectable security-groups (for the chat picker) + .route("/my/security-groups", get(run_context::my_security_groups)) // Session tool_group assignment (runtime) .route("/sessions/{session_id}/run-context", put(run_context::set_session_run_context)) // MCP / Connectors (blueprint §14/§15) diff --git a/src/frontend/api/roles.rs b/src/frontend/api/roles.rs index 5e8abbf..a9f7561 100644 --- a/src/frontend/api/roles.rs +++ b/src/frontend/api/roles.rs @@ -32,6 +32,9 @@ pub async fn create( if body.label.trim().is_empty() { return Err(ApiError::bad_request("label must not be empty")); } + if body.permission_group.trim().is_empty() { + return Err(ApiError::bad_request("permission group must not be empty")); + } roles::insert(skald.db(), id, body.label.trim(), &body.permission_group, body.attrs.as_deref()) .await?; // Seed the standard self-service Connector capabilities (§14): a new role can @@ -57,6 +60,12 @@ pub async fn update( if id == ADMIN_ROLE_ID { return Err(ApiError::bad_request("the built-in admin role cannot be modified")); } + if body.label.trim().is_empty() { + return Err(ApiError::bad_request("label must not be empty")); + } + if body.permission_group.trim().is_empty() { + return Err(ApiError::bad_request("permission group must not be empty")); + } let ok = roles::update(skald.db(), &id, body.label.trim(), &body.permission_group, body.attrs.as_deref()) .await?; if !ok { diff --git a/src/frontend/api/run_context.rs b/src/frontend/api/run_context.rs index ede21fb..d6d8024 100644 --- a/src/frontend/api/run_context.rs +++ b/src/frontend/api/run_context.rs @@ -5,9 +5,10 @@ use axum::{ extract::{Path, State}, http::StatusCode, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use skald_core::db::roles; use skald_core::skald::Skald; use super::{ApiError, guard::AuthUser, require_context}; @@ -79,6 +80,54 @@ pub async fn duplicate_group( Ok(Json(json!({ "id": body.id }))) } +// ── GET /api/my/security-groups — the caller's selectable groups ────────────── + +#[derive(Serialize)] +pub struct MySecurityGroup { + pub id: String, + pub name: String, + pub is_default: bool, +} + +/// The security-groups the calling user may pick in the chat picker: its role's +/// effective set joined with the group names (`admin` → every group). The composer +/// renders this like the model list; the server still enforces the set on write. +pub async fn my_security_groups( + State(skald): State>, + Extension(auth): Extension, +) -> Result>, ApiError> { + let user = skald + .users() + .get(&auth.user_id) + .await? + .ok_or_else(|| ApiError::not_found("user not found"))?; + + let all = skald.run_context_manager().list_groups().await?; + + let (allowed, default_id): (Vec, String) = if user.role_id == roles::ADMIN_ROLE_ID { + (all.iter().map(|g| g.id.clone()).collect(), "default".to_string()) + } else { + match roles::get(skald.db(), &user.role_id).await? { + Some(role) => (role.effective_groups(), role.permission_group.clone()), + None => (vec!["default".to_string()], "default".to_string()), + } + }; + + // Keep only ids that still exist as groups; carry the display name from there. + let out = allowed + .into_iter() + .filter_map(|id| { + all.iter().find(|g| g.id == id).map(|g| MySecurityGroup { + is_default: g.id == default_id, + id: g.id.clone(), + name: g.name.clone(), + }) + }) + .collect(); + + Ok(Json(out)) +} + // ── Session run_context assignment ──────────────────────────────────────────── #[derive(Deserialize)] @@ -92,6 +141,30 @@ pub async fn set_session_run_context( Json(ctx): Json>, ) -> Result, ApiError> { let uctx = require_context(&skald, &auth.user_id).await?; + + // Gate the requested context by the caller's role: a non-admin may only pick a + // security-group in its role's set, and every other RunContext field is dropped + // (fs-escalation hardening). admin passes through. Same validator the WS path uses. + let user = skald + .users() + .get(&auth.user_id) + .await? + .ok_or_else(|| ApiError::not_found("user not found"))?; + let ctx = match skald_core::run_context::validate_run_context_for_role( + skald.db(), + &user.role_id, + ctx, + ) + .await? + { + skald_core::run_context::RunContextDecision::Apply(c) => c, + skald_core::run_context::RunContextDecision::Forbidden(g) => { + return Err(ApiError::forbidden(format!( + "security group '{g}' is not allowed for your role" + ))); + } + }; + // The session row (and its live handler) live in the caller's own pool, so the // persist + live update both target the user's context. Run-context *definitions* // (roles) remain instance-wide; only the per-session value is owner data. diff --git a/src/frontend/api/sessions.rs b/src/frontend/api/sessions.rs index 55bf783..f19e78c 100644 --- a/src/frontend/api/sessions.rs +++ b/src/frontend/api/sessions.rs @@ -10,8 +10,9 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sqlx::SqlitePool; -use skald_core::db::{chat_history, chat_llm_tools, chat_sessions, chat_sessions_stack, sources}; +use skald_core::db::{chat_history, chat_llm_tools, chat_sessions, chat_sessions_stack, roles, sources}; use skald_core::db::chat_sessions_stack::SessionStack; +use skald_core::run_context::RunContext; use std::sync::Arc; use skald_core::skald::{Skald, UserContext}; use skald_core::session::handler::ApprovalDecision; @@ -39,10 +40,32 @@ pub async fn create( // Resolve agent + RunContext from the source so project chats reset with the // coordinator agent (not the default `main`), then provision a fresh session. let (agent, rc) = super::projects::provisioning_for_source(&ctx.pool, &q.source).await?; + // A non-project chat inherits the caller role's default security-group, so a + // restricted role starts scoped instead of on the catch-all `default` group. + // Project chats already carry their own run-context and are left untouched. + let rc = match rc { + Some(rc) => Some(rc), + None => role_default_run_context(&skald, &auth.user_id).await?, + }; ctx.chat_hub.provision_session(&q.source, &agent, rc.as_ref(), true).await?; Ok(Json(json!({}))) } +/// The default security-group a new session gets from the owner's role, or `None` +/// when the role points at the catch-all `default` group (nothing to pin). +async fn role_default_run_context( + skald: &Skald, + user_id: &str, +) -> Result, ApiError> { + let Some(user) = skald.users().get(user_id).await? else { return Ok(None) }; + let Some(role) = roles::get(skald.db(), &user.role_id).await? else { return Ok(None) }; + let group = role.permission_group; + if group.is_empty() || group == "default" { + return Ok(None); + } + Ok(Some(RunContext::with_security_group(Some(group)))) +} + // ── GET /api/web/messages ───────────────────────────────────────────────────── pub async fn web_messages( diff --git a/src/frontend/api/setup.rs b/src/frontend/api/setup.rs index 0f6a0bb..49c641a 100644 --- a/src/frontend/api/setup.rs +++ b/src/frontend/api/setup.rs @@ -22,6 +22,24 @@ pub async fn status(State(skald): State>) -> Result Ok(Json(SetupStatus { needs_setup: count == 0 })) } +// ── GET /api/setup/profiles — seed profiles offered by the picker ──────────── + +#[derive(Serialize)] +pub struct SeedProfileInfo { + pub id: String, + pub label: String, +} + +/// The seed profiles the first-run picker offers (§0.1: the neutral mechanism, +/// domain flavour in the data). Pre-auth, so it is on the setup allowlist. +pub async fn profiles() -> Json> { + let list = skald_core::setup::seed_profiles() + .into_iter() + .map(|p| SeedProfileInfo { id: p.id.to_string(), label: p.label.to_string() }) + .collect(); + Json(list) +} + // ── POST /api/setup/user — create the first (admin) user ──────────────────── #[derive(Deserialize)] @@ -33,6 +51,9 @@ pub struct CreateUserBody { /// Chosen interface language — becomes the instance default (`ui_locale`). #[serde(default)] pub locale: Option, + /// Chosen seed profile id. Defaults to the first shipped profile. + #[serde(default)] + pub profile: Option, } #[derive(Serialize)] @@ -63,17 +84,31 @@ pub async fn create_user( return Err(ApiError::bad_request("unsupported locale")); } } - - let id = skald - .users() - .register_user(username, None, "admin", Some(&body.password), body.encrypted) - .await?; - - // The first-run language choice is instance-wide: it lands in the registry - // config as the default every user follows until they override it. - if let Some(l) = locale { - skald.config().set(skald_core::i18n::DEFAULT_LOCALE_KEY, l).await?; + let profile = body + .profile + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("family"); + if skald_core::setup::seed_profile(profile).is_none() { + return Err(ApiError::bad_request("unknown seed profile")); } + // The shared first-run seam: seed the profile's roles, create the admin, set + // the default locale — the same path skald-setup takes, so the two never drift. + let id = skald_core::setup::initialize_instance( + skald.users(), + skald.db(), + profile, + skald_core::setup::FirstAdmin { + username, + display_name: None, + password: Some(&body.password), + encrypted: body.encrypted, + locale, + }, + ) + .await?; + Ok(Json(CreateUserResult { user_id: id })) } diff --git a/src/frontend/api/ws.rs b/src/frontend/api/ws.rs index 1fbf5b6..858c99a 100644 --- a/src/frontend/api/ws.rs +++ b/src/frontend/api/ws.rs @@ -116,6 +116,23 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc, source: String, running: session_handler.is_processing(), })).await; + // Tell this (possibly reloaded) client the session's current security-group so + // the chat picker starts in sync. The twin of the model pill — but the group is + // per-session persisted, not a per-source RAM pin, so it must be sent on connect. + let _ = socket.send(to_msg(&ServerEvent::SecurityGroupSelected { + group: current_session_group(&ctx.pool, &source).await, + })).await; + + // Keepalive: a long, silent turn (e.g. a slow `execute_cmd` producing no + // events for a minute) sends nothing over the socket, so an idle proxy or the + // browser can drop it. A dropped socket loses any event broadcast during the + // ~2s reconnect gap — the bus is a `broadcast` with no replay — which is what + // left an approved tool card stuck on "running" until a manual reload. A + // periodic Ping keeps the connection warm. 25s beats common ~60s idle timeouts. + let mut keepalive = tokio::time::interval(std::time::Duration::from_secs(25)); + keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + keepalive.tick().await; // consume the immediate first tick (don't ping on connect) + loop { tokio::select! { // ── Inbound: message from the browser ──────────────────────────── @@ -151,6 +168,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc, source: String, if handle_question_answer_msg(&text, &session_handler).await { continue; } if handle_data_msg(&text, &skald) { continue; } if handle_select_client_msg(&text, &source, &chat_hub).await { continue; } + if handle_select_security_group_msg(&text, &source, &user_id, &skald, &ctx, &session_handler).await { continue; } // ── /sethome ────────────────────────────────────────────────── let client_msg: ClientMessage = match serde_json::from_str(&text) { @@ -414,6 +432,13 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc, source: String, Err(broadcast::error::RecvError::Closed) => return, } } + + // ── Keepalive tick: ping the client to keep the socket warm ─────── + _ = keepalive.tick() => { + if socket.send(Message::Ping(Default::default())).await.is_err() { + return; + } + } } } } @@ -496,6 +521,84 @@ async fn handle_select_client_msg( true } +/// Returns true if the message was a `select_security_group` control message +/// (caller should `continue`). The twin of [`handle_select_client_msg`] for the +/// session security-group: validate the requested group against the caller's role +/// (§0.1 — enforce server-side, never trust the client: a non-admin may only pick a +/// group in its role's set, and no other `RunContext` field is honoured), persist it +/// on the session row, update the live handler, and broadcast `SecurityGroupSelected` +/// so every open client stays in sync. +async fn handle_select_security_group_msg( + text: &str, + source: &str, + user_id: &str, + skald: &Arc, + ctx: &Arc, + session_handler: &Arc, +) -> bool { + use skald_core::run_context::{RunContext, RunContextDecision, validate_run_context_for_role}; + + let Ok(v) = serde_json::from_str::(text) else { return false }; + if v["type"].as_str() != Some("select_security_group") { return false } + + // `group` is a string (pick) or null/absent (clear → the role's default group). + let requested = v.get("group").and_then(|g| g.as_str()).map(str::to_string); + let incoming = requested.map(|g| RunContext::with_security_group(Some(g))); + + let Ok(Some(user)) = skald.users().get(user_id).await else { return true }; + let effective = match validate_run_context_for_role(skald.db(), &user.role_id, incoming).await { + Ok(RunContextDecision::Apply(rc)) => rc, + Ok(RunContextDecision::Forbidden(g)) => { + warn!(source, group = %g, "select_security_group: not in role's set — ignored"); + return true; + } + Err(e) => { + tracing::error!(error = %e, "select_security_group: validation failed"); + return true; + } + }; + + // Persist on the session row (owner pool) and update the live handler. + if let Ok(Some(sid)) = skald_core::db::sources::active_session_id(&ctx.pool, source).await { + let _ = skald_core::db::chat_sessions::set_run_context( + &ctx.pool, + sid, + effective.as_ref().map(|c| c.to_db()).as_deref(), + ) + .await; + } + session_handler.set_run_context(effective.clone()).await; + + // Broadcast the effective group id ("default" when cleared) to every client. + let group = effective + .as_ref() + .and_then(|rc| rc.tool_group_id().map(str::to_string)) + .unwrap_or_else(|| "default".to_string()); + ctx.chat_hub.emit(skald_core::events::GlobalEvent { + source: Some(source.to_string()), + session_id: None, + event: ServerEvent::SecurityGroupSelected { group }, + }); + true +} + +/// The active session's current security-group for `source`, or `"default"` when +/// no session or no run-context is set. Used to seed a freshly-connected client. +async fn current_session_group(pool: &sqlx::SqlitePool, source: &str) -> String { + use skald_core::run_context::RunContext; + let Ok(Some(sid)) = skald_core::db::sources::active_session_id(pool, source).await else { + return "default".to_string(); + }; + let group = skald_core::db::chat_sessions::find_by_id(pool, sid) + .await + .ok() + .flatten() + .and_then(|s| s.run_context) + .and_then(|s| RunContext::from_db(&s)) + .and_then(|rc| rc.tool_group_id().map(str::to_string)); + group.unwrap_or_else(|| "default".to_string()) +} + /// Returns true if the message was an inbound data push (caller should `continue`). /// Dispatches `{"type":"data","stream":"...","payload":{...}}` to the appropriate manager. fn handle_data_msg(text: &str, skald: &Arc) -> bool { diff --git a/uninstall.sh b/uninstall.sh new file mode 100644 index 0000000..87c3b15 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env sh +# uninstall.sh — remove Skald Circle and all its data +# +# Usage: +# ./uninstall.sh +# +# Stops the daemon (systemd on Linux, launchd on macOS), removes the +# service/agent file, then deletes the entire installation directory +# (config, database, everything). +# +# Set SKALD_DIR before running if you installed to a custom location: +# SKALD_DIR=/opt/skald-circle ./uninstall.sh + +set -eu + +# ── Colours (if terminal) ───────────────────────────────────────────────────── +if [ -t 1 ]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + BOLD='\033[1m' + NC='\033[0m' +else + RED=''; GREEN=''; YELLOW=''; BOLD=''; NC='' +fi + +info() { printf "${GREEN}%s${NC}\n" "$*"; } +warn() { printf "${YELLOW}⚠ %s${NC}\n" "$*"; } +err() { printf "${RED}✖ %s${NC}\n" "$*"; } + +# ── Determine install directory ─────────────────────────────────────────────── +# Default: the directory this script lives in (i.e. the bundle root). +if [ -n "${SKALD_DIR:-}" ]; then + INSTALL_DIR="$SKALD_DIR" +else + INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)" +fi + +# ── Detect OS ───────────────────────────────────────────────────────────────── +OS="$(uname -s)" + +echo "" +printf "\033[1m🗑️ Skald Circle — Uninstaller\033[0m\n" +echo "" +echo " This will permanently delete Skald Circle and all its data:" +echo " ${INSTALL_DIR}" +echo "" + +if [ -t 0 ]; then + printf "%s " "Are you sure? Type 'yes' to continue: " + read -r CONFIRM + [ "$CONFIRM" = "yes" ] || { echo "Aborted."; exit 0; } + echo "" +fi + +# ── Stop & remove daemon ────────────────────────────────────────────────────── +case "$OS" in + Linux) + SERVICE_NAME="skald-circle.service" + SERVICE_PATH="$HOME/.config/systemd/user/$SERVICE_NAME" + + if command -v systemctl >/dev/null 2>&1; then + if systemctl --user is-enabled "$SERVICE_NAME" >/dev/null 2>&1; then + info "⏹️ Stopping Skald Circle service …" + systemctl --user stop "$SERVICE_NAME" 2>/dev/null || true + systemctl --user disable "$SERVICE_NAME" 2>/dev/null || true + fi + + if [ -f "$SERVICE_PATH" ]; then + info "🗑️ Removing systemd service file …" + rm -f "$SERVICE_PATH" + systemctl --user daemon-reload 2>/dev/null || true + fi + else + warn "systemd not found — skipping service removal." + fi + ;; + + Darwin) + PLIST="$HOME/Library/LaunchAgents/com.skald.circle.plist" + + if [ -f "$PLIST" ]; then + info "⏹️ Stopping Skald Circle agent …" + launchctl unload "$PLIST" 2>/dev/null || true + info "🗑️ Removing launchd plist …" + rm -f "$PLIST" + else + warn "launchd plist not found at ${PLIST}" + fi + ;; + + *) + warn "Unknown OS: $OS — skipping daemon removal." + ;; +esac + +# ── Remove installation directory ───────────────────────────────────────────── +if [ -d "$INSTALL_DIR" ]; then + info "🗑️ Removing installation directory …" + rm -rf "$INSTALL_DIR" + info "✔ Removed ${INSTALL_DIR}" +else + warn "Installation directory not found: ${INSTALL_DIR}" +fi + +echo "" +info "✅ Skald Circle has been uninstalled." +echo " If you want to reinstall:" +echo " curl -fsSL https://builds.skaldagent.net/install.sh | bash" +echo " curl -fsSL https://builds.skaldagent.net/install-nightly.sh | bash" +echo "" diff --git a/web/components/copilot.js b/web/components/copilot.js index c554b23..95a8e50 100644 --- a/web/components/copilot.js +++ b/web/components/copilot.js @@ -25,6 +25,7 @@ export class AppCopilot extends I18nMixin(ChatSession) { _mode: { state: true }, _me: { state: true }, _modelOpen: { state: true }, + _groupOpen: { state: true }, _tabs: { state: true }, _activeSource: { state: true }, _cmdMenu: { state: true }, @@ -38,6 +39,7 @@ export class AppCopilot extends I18nMixin(ChatSession) { this._mode = 'dock'; this._me = null; this._modelOpen = false; + this._groupOpen = false; this._resizing = false; // Slash-command autocomplete: `_cmdMenu` is the filtered list currently shown // (null = hidden), `_cmdSel` the highlighted index, `_allCommands` the merged @@ -62,6 +64,7 @@ export class AppCopilot extends I18nMixin(ChatSession) { this._restoreState(); this._loadCommands(); this._loadMe(); + this._loadSecurityGroups(); // Same element, two layouts: the chat is the home page ('full') and docks // to the side on every other route — state is never lost, it only resizes. this._applyMode(this._pageFromHash() === 'home' ? 'full' : 'dock'); @@ -450,6 +453,29 @@ export class AppCopilot extends I18nMixin(ChatSession) { ` : nothing} + ${this._securityGroups.length > 1 ? html` +
+ ${this._groupOpen ? html` +
{ this._groupOpen = false; }}>
+
+ ${this._securityGroups.map(g => html` + + `)} +
+ ` : nothing} + +
+ ` : nothing} + + `; + } +} diff --git a/crates/plugin-honcho/web/i18n.js b/crates/plugin-honcho/web/i18n.js new file mode 100644 index 0000000..783cf86 --- /dev/null +++ b/crates/plugin-honcho/web/i18n.js @@ -0,0 +1,109 @@ +// Frontend translations for the Honcho page fragments. +// +// Served at `/api/plugin/honcho/web/i18n.js` and imported by `common.js`, which +// registers it into the host's shared dictionaries via `addStrings` (see +// `web/lib/i18n.js`). Keys are namespaced `plugin.honcho.*` so they never +// collide with core keys. These are the *frontend* UI strings; the plugin's +// backend error strings live in `../i18n/*.json` and reach the browser already +// translated as HTTP response text. +const P = 'plugin.honcho'; + +export default { + en: { + // Admin config page + [`${P}.config.title`]: 'Honcho — Long-term memory', + [`${P}.config.intro`]: 'Connect the Honcho memory server. When enabled, each user can opt in from their own Long-term memory page; nothing leaves the box until they do.', + [`${P}.config.enabled`]: 'Plugin enabled', + [`${P}.config.base_url`]: 'Server URL', + [`${P}.config.base_url_hint`]: 'e.g. http://localhost:8000', + [`${P}.config.api_key`]: 'API key', + [`${P}.config.api_key_hint`]: 'Leave empty for a local, unauthenticated instance.', + [`${P}.config.workspace`]: 'Workspace ID', + [`${P}.config.workspace_hint`]:'One shared workspace for the whole instance; each user is a separate peer inside it.', + [`${P}.config.save`]: 'Save', + [`${P}.config.saved`]: 'Saved.', + [`${P}.config.test`]: 'Test connection', + [`${P}.config.testing`]: 'Testing…', + [`${P}.config.test_ok`]: 'Connected — {n} workspace(s) reachable.', + [`${P}.config.required`]: 'The server URL is required.', + [`${P}.config.loading`]: 'Loading…', + [`${P}.config.not_found`]: 'Honcho plugin not found.', + + // User opt-in page + [`${P}.memory.title`]: 'Long-term memory', + [`${P}.memory.intro`]: 'Let the assistant remember you across conversations, so it gets more helpful over time.', + [`${P}.memory.privacy_title`]: 'Before you turn this on', + [`${P}.memory.privacy_body`]: 'Your messages are stored in cleartext on the Honcho memory server, outside your encrypted database. Turn this on only if you are comfortable with that. It is off unless you enable it, and you can turn it off at any time.', + [`${P}.memory.toggle`]: 'Remember me across conversations', + [`${P}.memory.save`]: 'Save', + [`${P}.memory.saved`]: 'Saved.', + [`${P}.memory.loading`]: 'Loading…', + [`${P}.memory.unavailable`]: 'Long-term memory is not available to you yet. Ask your administrator to grant access.', + [`${P}.memory.soon_title`]: 'Coming soon', + [`${P}.memory.soon_body`]: 'Soon you will be able to ask Honcho what it remembers about you, and manage it, right from this page.', + }, + + it: { + [`${P}.config.title`]: 'Honcho — Memoria a lungo termine', + [`${P}.config.intro`]: 'Collega il server di memoria Honcho. Quando è attivo, ogni utente può dare il consenso dalla propria pagina Memoria a lungo termine; finché non lo fa, nulla lascia il box.', + [`${P}.config.enabled`]: 'Plugin attivo', + [`${P}.config.base_url`]: 'URL del server', + [`${P}.config.base_url_hint`]: 'es. http://localhost:8000', + [`${P}.config.api_key`]: 'Chiave API', + [`${P}.config.api_key_hint`]: 'Lascia vuoto per un’istanza locale senza autenticazione.', + [`${P}.config.workspace`]: 'ID workspace', + [`${P}.config.workspace_hint`]:'Un solo workspace condiviso per l’intera istanza; ogni utente è un peer separato al suo interno.', + [`${P}.config.save`]: 'Salva', + [`${P}.config.saved`]: 'Salvato.', + [`${P}.config.test`]: 'Prova connessione', + [`${P}.config.testing`]: 'Verifica…', + [`${P}.config.test_ok`]: 'Connesso — {n} workspace raggiungibili.', + [`${P}.config.required`]: 'L’URL del server è obbligatorio.', + [`${P}.config.loading`]: 'Caricamento…', + [`${P}.config.not_found`]: 'Plugin Honcho non trovato.', + + [`${P}.memory.title`]: 'Memoria a lungo termine', + [`${P}.memory.intro`]: 'Permetti all’assistente di ricordarti tra una conversazione e l’altra, così diventa più utile nel tempo.', + [`${P}.memory.privacy_title`]: 'Prima di attivarla', + [`${P}.memory.privacy_body`]: 'I tuoi messaggi vengono memorizzati in chiaro sul server di memoria Honcho, fuori dal tuo database cifrato. Attivala solo se ti sta bene. È disattivata finché non la abiliti, e puoi disattivarla in qualsiasi momento.', + [`${P}.memory.toggle`]: 'Ricordami tra le conversazioni', + [`${P}.memory.save`]: 'Salva', + [`${P}.memory.saved`]: 'Salvato.', + [`${P}.memory.loading`]: 'Caricamento…', + [`${P}.memory.unavailable`]: 'La memoria a lungo termine non è ancora disponibile per te. Chiedi all’amministratore di darti l’accesso.', + [`${P}.memory.soon_title`]: 'In arrivo', + [`${P}.memory.soon_body`]: 'Presto potrai chiedere a Honcho cosa ricorda di te e gestirlo, direttamente da questa pagina.', + }, + + fr: { + [`${P}.config.title`]: 'Honcho — Mémoire à long terme', + [`${P}.config.intro`]: 'Connectez le serveur de mémoire Honcho. Une fois activé, chaque utilisateur peut consentir depuis sa page Mémoire à long terme ; rien ne quitte la machine tant qu’il ne l’a pas fait.', + [`${P}.config.enabled`]: 'Plugin activé', + [`${P}.config.base_url`]: 'URL du serveur', + [`${P}.config.base_url_hint`]: 'ex. http://localhost:8000', + [`${P}.config.api_key`]: 'Clé API', + [`${P}.config.api_key_hint`]: 'Laissez vide pour une instance locale sans authentification.', + [`${P}.config.workspace`]: 'ID de l’espace', + [`${P}.config.workspace_hint`]:'Un seul espace partagé pour toute l’instance ; chaque utilisateur y est un peer distinct.', + [`${P}.config.save`]: 'Enregistrer', + [`${P}.config.saved`]: 'Enregistré.', + [`${P}.config.test`]: 'Tester la connexion', + [`${P}.config.testing`]: 'Test…', + [`${P}.config.test_ok`]: 'Connecté — {n} espace(s) accessibles.', + [`${P}.config.required`]: 'L’URL du serveur est obligatoire.', + [`${P}.config.loading`]: 'Chargement…', + [`${P}.config.not_found`]: 'Plugin Honcho introuvable.', + + [`${P}.memory.title`]: 'Mémoire à long terme', + [`${P}.memory.intro`]: 'Laissez l’assistant se souvenir de vous d’une conversation à l’autre, pour qu’il devienne plus utile avec le temps.', + [`${P}.memory.privacy_title`]: 'Avant d’activer', + [`${P}.memory.privacy_body`]: 'Vos messages sont stockés en clair sur le serveur de mémoire Honcho, en dehors de votre base chiffrée. N’activez que si cela vous convient. C’est désactivé tant que vous ne l’activez pas, et vous pouvez le désactiver à tout moment.', + [`${P}.memory.toggle`]: 'Se souvenir de moi entre les conversations', + [`${P}.memory.save`]: 'Enregistrer', + [`${P}.memory.saved`]: 'Enregistré.', + [`${P}.memory.loading`]: 'Chargement…', + [`${P}.memory.unavailable`]: 'La mémoire à long terme ne vous est pas encore accessible. Demandez l’accès à votre administrateur.', + [`${P}.memory.soon_title`]: 'Bientôt disponible', + [`${P}.memory.soon_body`]: 'Bientôt, vous pourrez demander à Honcho ce qu’il retient de vous et le gérer, directement depuis cette page.', + }, +}; diff --git a/crates/plugin-honcho/web/memory.js b/crates/plugin-honcho/web/memory.js new file mode 100644 index 0000000..6776838 --- /dev/null +++ b/crates/plugin-honcho/web/memory.js @@ -0,0 +1,135 @@ +// Honcho user opt-in page (page_id `memory`, visible to any user with a +// `plugin_access` grant). +// +// The per-user consent to long-term memory. Reuses the core per-user config +// endpoints — `GET /api/plugins/mine` to read the current flag, +// `PUT /api/plugins/honcho/my-config` to save `{ enabled }` — so this fragment +// needs no backend of its own. Structured in sections so the future "what does +// Honcho know about me?" panel is a drop-in addition (see the `soon` section). +// Default-exports the element class; the host registers it. +import { html, nothing } from 'lit'; +import { HonchoBase, jf, t } from './common.js'; + +const P = 'plugin.honcho'; +const ID = 'honcho'; + +export default class HonchoMemoryPage extends HonchoBase { + static get properties() { + return { + _row: { state: true }, // UserPluginView | null (null once loaded = not granted) + _enabled: { state: true }, // draft toggle + _status: { state: true }, // { ok?, err? } + _error: { state: true }, + _loading: { state: true }, + }; + } + + constructor() { + super(); + this._row = null; + this._enabled = false; + this._status = {}; + this._error = null; + this._loading = true; + } + + connectedCallback() { + super.connectedCallback(); + this._load(); + } + + async _load() { + this._loading = true; + this._error = null; + try { + const mine = await jf('/api/plugins/mine'); + const row = (mine ?? []).find(x => x.id === ID) ?? null; + this._row = row; + this._enabled = !!row?.user_config?.enabled; + } catch (e) { + this._error = e.message; + } finally { + this._loading = false; + } + } + + async _save() { + this._status = {}; + try { + await jf(`/api/plugins/${ID}/my-config`, { + method: 'PUT', + body: JSON.stringify({ enabled: this._enabled }), + }); + this._status = { ok: t(`${P}.memory.saved`) }; + await this._load(); + } catch (e) { + this._status = { err: e.message }; + } + } + + render() { + return html` +
+
+

${t(`${P}.memory.title`)}

+
+
+ ${this._error ? html`
${this._error}
` : nothing} + ${this._loading + ? html`
${t(`${P}.memory.loading`)}
` + : this._row ? this._renderBody() : this._renderUnavailable()} +
+
`; + } + + _renderUnavailable() { + return html` +
+ +

${t(`${P}.memory.unavailable`)}

+
`; + } + + _renderBody() { + return html` +

${t(`${P}.memory.intro`)}

+ +
+
+ ${t(`${P}.memory.privacy_title`)} +
+
+ ${t(`${P}.memory.privacy_body`)} +
+
+ +
+ { this._enabled = e.target.checked; this._status = {}; }} /> + +
+ + ${this._status.err ? html`
${this._status.err}
` : nothing} + ${this._status.ok ? html`
${this._status.ok}
` : nothing} + + + + ${this._renderSoon()}`; + } + + // Placeholder for the future "what does Honcho know about me?" panel. When + // built, this section gains a button that calls a new `GET ${this.api}/whoami` + // (opt-in-gated) and renders the returned summary; only this method + that one + // route change. + _renderSoon() { + if (!this._enabled) return nothing; + return html` +
+
+
${t(`${P}.memory.soon_title`)}
+
${t(`${P}.memory.soon_body`)}
+
`; + } +} diff --git a/crates/skald-core/src/plugin/mod.rs b/crates/skald-core/src/plugin/mod.rs index b8901a9..13909a4 100644 --- a/crates/skald-core/src/plugin/mod.rs +++ b/crates/skald-core/src/plugin/mod.rs @@ -66,6 +66,10 @@ pub struct PluginPageInfo { pub icon: String, pub priority: i32, pub entry_url: String, + /// Mirrors [`core_api::plugin::PluginPage::admin_only`]. Lets the admin + /// Plugins UI recognise a plugin's own config page and defer to it (hide the + /// generic `config_schema` form, link out instead). + pub admin_only: bool, /// Fragment-contract version the host speaks. Always 1 for now — bump when /// the contract changes so old hosts can refuse new fragments cleanly. pub api_version: u32, @@ -493,6 +497,7 @@ impl PluginManager { icon: page.icon.to_string(), priority: page.priority, entry_url: format!("/api/plugin/{}/{}", plugin.id(), page.entry), + admin_only: page.admin_only, api_version: 1, }); } diff --git a/crates/skald-core/src/skald/accessors.rs b/crates/skald-core/src/skald/accessors.rs index d780f3a..1ec6e19 100644 --- a/crates/skald-core/src/skald/accessors.rs +++ b/crates/skald-core/src/skald/accessors.rs @@ -186,6 +186,18 @@ impl UserChannelApi for Skald { .unwrap_or(false) } + async fn is_admin(&self, user_id: &str) -> bool { + // Built-in admin role; an unknown user or a lookup error fails closed. + sqlx::query_as::<_, (String,)>("SELECT role_id FROM users WHERE id = ?") + .bind(user_id) + .fetch_optional(self.db().as_ref()) + .await + .ok() + .flatten() + .map(|(r,)| r == crate::db::roles::ADMIN_ROLE_ID) + .unwrap_or(false) + } + async fn user_for_session(&self, token: &str) -> Option { self.sessions().user_of(token) } diff --git a/web/components/plugin-detail.js b/web/components/plugin-detail.js index 3a11758..5e7abc2 100644 --- a/web/components/plugin-detail.js +++ b/web/components/plugin-detail.js @@ -28,6 +28,7 @@ export class PluginDetailPage extends LightElement { _open: { state: true }, _id: { state: true }, _plugin: { state: true }, // PluginInfo + _customPage: { state: true }, // this plugin's own admin page, or null _error: { state: true }, _draft: { state: true }, // config form draft _status: { state: true }, // { ok?: string, err?: string } @@ -47,6 +48,7 @@ export class PluginDetailPage extends LightElement { _reset() { this._id = null; this._plugin = null; + this._customPage = null; this._error = null; this._draft = null; this._status = {}; @@ -95,6 +97,12 @@ export class PluginDetailPage extends LightElement { return; } this._plugin = p; + // If the plugin ships its own admin page (an `admin_only` web-page), the + // generic config form defers to it — see `_renderConfig`. + try { + const pages = await jf('/api/plugins/pages'); + this._customPage = (pages ?? []).find(pg => pg.plugin_id === this._id && pg.admin_only) ?? null; + } catch { this._customPage = null; } // Keep whatever the admin has already typed across a reload triggered by a save. this._draft = { ...(p.config || {}), ...(this._draft || {}) }; // Binding-managed plugins (e.g. mobile-connector) gate access through @@ -244,8 +252,31 @@ export class PluginDetailPage extends LightElement { `; } + _openCustomPage(e, route) { + e.preventDefault(); + history.pushState({ page: route }, '', '#' + route); + window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: route } })); + } + + _renderConfigLink() { + const cp = this._customPage; + const route = `plugin/${cp.plugin_id}/${cp.page_id}`; + return html` +
+
+

${t('plugins.detail.config.title')}

+
+
${t('plugins.detail.config.custom_page')}
+ this._openCustomPage(e, route)}> + ${t('plugins.detail.config.open')} + +
`; + } + _renderConfig() { const p = this._plugin; + // Defer to the plugin's own admin page when it ships one. + if (this._customPage) return this._renderConfigLink(); const fields = schemaFields(p.config_schema); const draft = this._draft || {}; return html` diff --git a/web/i18n/en.js b/web/i18n/en.js index 0589870..a351fbc 100644 --- a/web/i18n/en.js +++ b/web/i18n/en.js @@ -881,6 +881,8 @@ export default { 'plugins.detail.back': 'Back to catalog', 'plugins.detail.config.title': 'Instance configuration', 'plugins.detail.config.empty': 'This plugin has no instance settings.', + 'plugins.detail.config.custom_page': 'This plugin has its own configuration page.', + 'plugins.detail.config.open': 'Open configuration', 'plugins.detail.access.title': 'User access', 'plugins.detail.not_found': 'No plugin named "{id}".', diff --git a/web/i18n/fr.js b/web/i18n/fr.js index 34337cf..f9294de 100644 --- a/web/i18n/fr.js +++ b/web/i18n/fr.js @@ -871,6 +871,8 @@ export default { 'plugins.detail.back': 'Retour au catalogue', 'plugins.detail.config.title': 'Configuration de l’instance', 'plugins.detail.config.empty': 'Ce plugin n’a aucun réglage d’instance.', + 'plugins.detail.config.custom_page': 'Ce plugin possède sa propre page de configuration.', + 'plugins.detail.config.open': 'Ouvrir la configuration', 'plugins.detail.access.title': 'Accès utilisateurs', 'plugins.detail.not_found': 'Aucun plugin nommé « {id} ».', diff --git a/web/i18n/it.js b/web/i18n/it.js index 89ed995..22268ef 100644 --- a/web/i18n/it.js +++ b/web/i18n/it.js @@ -871,6 +871,8 @@ export default { 'plugins.detail.back': 'Torna al catalogo', 'plugins.detail.config.title': 'Configurazione istanza', 'plugins.detail.config.empty': 'Questo plugin non ha impostazioni di istanza.', + 'plugins.detail.config.custom_page': 'Questo plugin ha una propria pagina di configurazione.', + 'plugins.detail.config.open': 'Apri configurazione', 'plugins.detail.access.title': 'Accesso utenti', 'plugins.detail.not_found': 'Nessun plugin chiamato "{id}".', From bba84a22ffa5761965e976b1ba90701bcab09a6a Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Mon, 20 Jul 2026 15:36:09 +0100 Subject: [PATCH 18/42] Fix package workflow: add missing --os linux argument ci/package.sh requires --os (linux|darwin) but both nightly.yml and release.yml were calling it without --os, causing the build to fail with 'Missing required argument'. Add --os linux to all 4 package steps (amd64 + arm64 in each workflow). --- .gitea/workflows/nightly.yml | 2 ++ .gitea/workflows/release.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index 355a0c0..7369585 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -34,6 +34,7 @@ jobs: cd "${GITHUB_WORKSPACE:-.}" ./ci/package.sh \ --version nightly \ + --os linux \ --arch amd64 \ --target-dir /home/dguiducci/.cache/skald-ci/target/release \ --output dist/ @@ -43,6 +44,7 @@ jobs: cd "${GITHUB_WORKSPACE:-.}" ./ci/package.sh \ --version nightly \ + --os linux \ --arch arm64 \ --target-dir /home/dguiducci/.cache/skald-ci/target/aarch64-unknown-linux-gnu/release \ --output dist/ diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 2244b81..a4ce55a 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -64,6 +64,7 @@ jobs: cd "${GITHUB_WORKSPACE:-.}" ./ci/package.sh \ --version "${{ steps.extract-version.outputs.version }}" \ + --os linux \ --arch amd64 \ --target-dir /home/dguiducci/.cache/skald-ci/target/release \ --output dist/ @@ -73,6 +74,7 @@ jobs: cd "${GITHUB_WORKSPACE:-.}" ./ci/package.sh \ --version "${{ steps.extract-version.outputs.version }}" \ + --os linux \ --arch arm64 \ --target-dir /home/dguiducci/.cache/skald-ci/target/aarch64-unknown-linux-gnu/release \ --output dist/ From beeff617018fbd6ae4cfa8b816ce426294e8ebdf Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Mon, 20 Jul 2026 16:29:59 +0100 Subject: [PATCH 19/42] Include providers.yaml in distribution tarball MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The providers.yaml template (declarative OpenAI-compatible LLM providers) was missing from the package — it's a default config file like default.config.yaml and should ship with every install. --- ci/package.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ci/package.sh b/ci/package.sh index 6547001..1384f48 100755 --- a/ci/package.sh +++ b/ci/package.sh @@ -17,7 +17,7 @@ # # The tarball contains everything needed to run (or uninstall) Skald Circle: # bin/skald, bin/skald-setup, web/, agents/, skills/, -# default.config.yaml, requirements.txt, run.sh, uninstall.sh +# default.config.yaml, providers.yaml, requirements.txt, run.sh, uninstall.sh set -eu @@ -93,6 +93,7 @@ cp -r web "$STAGING/web" cp -r agents "$STAGING/agents" cp -r skills "$STAGING/skills" cp default.config.yaml "$STAGING/default.config.yaml" +cp providers.yaml "$STAGING/providers.yaml" cp requirements.txt "$STAGING/requirements.txt" cp run.sh "$STAGING/run.sh" cp uninstall.sh "$STAGING/uninstall.sh" From 6b25e7a2bf2e90f89cd523056f068be9513071af Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Mon, 20 Jul 2026 16:31:17 +0100 Subject: [PATCH 20/42] Sidebar: group nav into priority-ordered functional sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn the flat sidebar into a data-driven nav: each entry declares a group (workspace/extensions/config/dev) and a numeric priority, and each section renders by sorting on that key. Split axis is function, not permission — adminOnly/debugOnly gate individual entries and an empty section is hidden, so Configuration vanishes for non-admins without a section-level role check. - Shared folders moves into 'Your space' (still admin-gated per-entry). - Plugin pages merge into the workspace group on the same priority line (>=100 by default); rebase the two in-repo plugins accordingly. - Configuration and Development are collapsible, closed by default, state persisted in localStorage. Simple mode unchanged. - Rename nav.catalog to 'Connectors Catalog' to disambiguate from the Plugin Catalog; add nav.section.* header keys (en/it/fr). - Replace dead .sidebar-section-toggle CSS with real section classes. --- crates/plugin-honcho/src/lib.rs | 6 +- crates/plugin-mobile-connector/src/lib.rs | 6 +- web/components/sidebar.js | 323 +++++++++++++--------- web/css/sidebar.css | 46 ++- web/i18n/en.js | 6 +- web/i18n/fr.js | 6 +- web/i18n/it.js | 6 +- 7 files changed, 240 insertions(+), 159 deletions(-) diff --git a/crates/plugin-honcho/src/lib.rs b/crates/plugin-honcho/src/lib.rs index 42af13c..8944218 100644 --- a/crates/plugin-honcho/src/lib.rs +++ b/crates/plugin-honcho/src/lib.rs @@ -863,7 +863,9 @@ impl core_api::plugin::Plugin for HonchoPlugin { icon: "gear", entry: "web/config.js".into(), admin_only: true, - priority: 10, + // Sidebar priority: core "Your space" items live in 10–90, so + // plugin pages use ≥100 to land after them (see sidebar.js NAV). + priority: 120, }, PluginPage { page_id: "memory", @@ -871,7 +873,7 @@ impl core_api::plugin::Plugin for HonchoPlugin { icon: "stars", entry: "web/memory.js".into(), admin_only: false, - priority: 10, + priority: 130, }, ] } diff --git a/crates/plugin-mobile-connector/src/lib.rs b/crates/plugin-mobile-connector/src/lib.rs index ab7a1d1..80daaf0 100644 --- a/crates/plugin-mobile-connector/src/lib.rs +++ b/crates/plugin-mobile-connector/src/lib.rs @@ -321,7 +321,9 @@ impl Plugin for MobileConnectorPlugin { icon: "qr-code", entry: "web/pairing.js".into(), admin_only: true, - priority: 10, + // Sidebar priority: core "Your space" items live in 10–90, so + // plugin pages use ≥100 to land after them (see sidebar.js NAV). + priority: 100, }, PluginPage { page_id: "devices", @@ -329,7 +331,7 @@ impl Plugin for MobileConnectorPlugin { icon: "phone", entry: "web/devices.js".into(), admin_only: true, - priority: 20, + priority: 110, }, ] } diff --git a/web/components/sidebar.js b/web/components/sidebar.js index 4a026a9..0209f04 100644 --- a/web/components/sidebar.js +++ b/web/components/sidebar.js @@ -3,6 +3,66 @@ import { LightElement } from '../lib/base.js'; import { t, I18nMixin } from '../lib/i18n.js'; +// ── Navigation model ────────────────────────────────────────────────────────── +// The nav is data, not markup: every entry declares its `group` and a numeric +// `priority` (lower = higher up), and each group is rendered by sorting its +// entries on that key. Retuning the order is editing a number here, never moving +// JSX around. Plugin-contributed pages (`GET /api/plugins/pages`) carry their own +// `priority` (see `PluginPage`) and merge into the `workspace` group on the *same* +// number line — the convention is core items live in 10–90 and plugin pages ≥100, +// so a page lands after the daily items by default yet stays freely placeable. +// +// The split axis is **function**, not permission: `adminOnly`/`debugOnly` gate +// individual entries, and a whole section disappears when it has no visible entry +// for this user (so "Configuration" — all admin entries — vanishes for non-admins +// without a section-level role check). `aliases` lists extra `_activePage` values +// that should light the entry (e.g. the detail route paired with its list). +const NAV = [ + // Il tuo spazio — daily productivity. Visible to everyone (full mode). + { id: 'home', group: 'workspace', priority: 10, icon: 'chat-dots', labelKey: 'nav.chat' }, + { id: 'inbox', group: 'workspace', priority: 20, icon: 'inbox', labelKey: 'nav.inbox' }, + { id: 'dashboard', group: 'workspace', priority: 30, icon: 'speedometer2', labelKey: 'nav.dashboard' }, + { id: 'projects', group: 'workspace', priority: 40, icon: 'kanban', labelKey: 'nav.projects' }, + { id: 'tasks', group: 'workspace', priority: 50, icon: 'lightning-charge',labelKey: 'nav.tasks' }, + // Shared folders is admin-managed but *content*, so it lives with the daily + // items, not buried in Configuration — the link stays admin-gated per-entry. + { id: 'shared-folders', group: 'workspace', priority: 60, icon: 'folder-symlink', labelKey: 'nav.shared_folders', adminOnly: true }, + + // Estensioni — what the assistant is made of / can use. Visible to everyone; + // Agents is read-only for non-admins (editable only by the admin server-side). + { id: 'connectors', group: 'extensions', priority: 10, icon: 'plug', labelKey: 'nav.connectors', aliases: ['connector'] }, + { id: 'plugins', group: 'extensions', priority: 20, icon: 'puzzle', labelKey: 'nav.plugins' }, + { id: 'agents', group: 'extensions', priority: 30, icon: 'people', labelKey: 'nav.agents' }, + + // Configurazione — rarely-touched setup. Every entry is admin-only today, so + // the section is admin-only in effect via the empty-section rule. + { id: 'users', group: 'config', priority: 10, icon: 'person-badge', labelKey: 'nav.users', adminOnly: true }, + { id: 'roles', group: 'config', priority: 20, icon: 'tags', labelKey: 'nav.roles', adminOnly: true }, + { id: 'models', group: 'config', priority: 30, icon: 'cpu', labelKey: 'nav.models', adminOnly: true }, + { id: 'providers', group: 'config', priority: 40, icon: 'plug', labelKey: 'nav.providers', adminOnly: true }, + { id: 'approval', group: 'config', priority: 50, icon: 'shield-check', labelKey: 'nav.security', adminOnly: true }, + { id: 'plugin-catalog', group: 'config', priority: 70, icon: 'puzzle-fill', labelKey: 'nav.plugin_catalog', adminOnly: true, aliases: ['plugin-detail'] }, + { id: 'catalog', group: 'config', priority: 80, icon: 'journal-text', labelKey: 'nav.catalog', adminOnly: true, aliases: ['marketplace'] }, + { id: 'config', group: 'config', priority: 90, icon: 'gear', labelKey: 'nav.config', adminOnly: true }, + + // Sviluppo — debug surface, only with the debug flag on. + { id: 'llm-requests', group: 'dev', priority: 10, icon: 'journal-code', labelKey: 'nav.llm_requests', debugOnly: true }, + { id: 'tic', group: 'dev', priority: 20, icon: 'bell', labelKey: 'nav.tic', debugOnly: true }, +]; + +// Section order + which sections collapse. Configuration and Development are +// collapsible and closed by default (rarely touched); the two productivity +// sections are always open. +const GROUPS = [ + { id: 'workspace', labelKey: 'nav.section.workspace', collapsible: false }, + { id: 'extensions', labelKey: 'nav.section.extensions', collapsible: false }, + { id: 'config', labelKey: 'nav.section.config', collapsible: true }, + { id: 'dev', labelKey: 'nav.section.dev', collapsible: true }, +]; + +const COLLAPSE_KEY = 'sidebar-collapsed'; + + export class AppSidebar extends I18nMixin(LightElement) { static properties = { _activePage: { state: true }, @@ -12,6 +72,7 @@ export class AppSidebar extends I18nMixin(LightElement) { _recentProjects: { state: true }, _me: { state: true }, _pluginPages: { state: true }, + _collapsed: { state: true }, }; constructor() { @@ -24,6 +85,7 @@ export class AppSidebar extends I18nMixin(LightElement) { this._recentProjects = []; this._me = null; this._pluginPages = []; + this._collapsed = { config: true, dev: true }; } connectedCallback() { @@ -53,6 +115,7 @@ export class AppSidebar extends I18nMixin(LightElement) { // Poll inbox count independently of whether the page is open. this._pollInbox(); this._pollTimer = setInterval(() => this._pollInbox(), 10000); + this._loadCollapsed(); this._loadDebugMode(); this._loadRecentProjects(); this._loadMe(); @@ -76,6 +139,20 @@ export class AppSidebar extends I18nMixin(LightElement) { clearInterval(this._pollTimer); } + // Persisted per-section collapse state. Defaults (config + dev closed) apply + // until the user toggles a section, then their choice is remembered. + _loadCollapsed() { + try { + const saved = JSON.parse(localStorage.getItem(COLLAPSE_KEY) || '{}'); + this._collapsed = { config: true, dev: true, ...saved }; + } catch { /* keep defaults */ } + } + + _toggleSection(id) { + this._collapsed = { ...this._collapsed, [id]: !this._collapsed[id] }; + try { localStorage.setItem(COLLAPSE_KEY, JSON.stringify(this._collapsed)); } catch { /* ignore */ } + } + async _loadDebugMode() { try { const res = await fetch('/api/dev/debug_mode'); @@ -197,6 +274,107 @@ export class AppSidebar extends I18nMixin(LightElement) { this._applyPage('tasks'); } + // ── Entry-level helpers ───────────────────────────────────────────────────── + + _itemVisible(item) { + if (item.adminOnly && this._me?.role_id !== 'admin') return false; + if (item.debugOnly && !this._debugMode) return false; + return true; + } + + _isActive(item) { + if (this._activePage === item.id) return true; + return (item.aliases || []).includes(this._activePage); + } + + // Core entries of a group plus (workspace only) the plugin pages, merged on the + // shared `priority` line and sorted ascending (lower = higher up). + _entriesForGroup(groupId) { + const core = NAV + .filter((i) => i.group === groupId && this._itemVisible(i)) + .map((i) => ({ kind: 'core', priority: i.priority, item: i })); + const plugins = groupId === 'workspace' + ? this._pluginPages.map((p) => ({ kind: 'plugin', priority: p.priority ?? 100, page: p })) + : []; + return [...core, ...plugins].sort((a, b) => a.priority - b.priority); + } + + // ── Render helpers ────────────────────────────────────────────────────────── + + _renderGroup(group) { + const entries = this._entriesForGroup(group.id); + if (!entries.length) return nothing; // empty section → hidden + const collapsed = group.collapsible && this._collapsed[group.id]; + const header = group.collapsible + ? html` + ` + : html``; + return html` + `; + } + + _renderEntry(entry) { + if (entry.kind === 'plugin') return this._renderPluginEntry(entry.page); + const item = entry.item; + switch (item.id) { + case 'home': return this._renderHome(); + case 'inbox': return this._renderInbox(item); + case 'tasks': return this._renderTasksMenu(); + case 'projects': return html`${this._renderStdLink(item)}${this._renderRecentProjects()}`; + default: return this._renderStdLink(item); + } + } + + _renderStdLink(item) { + return html` + this._togglePage(item.id, e)}> + + ${t(item.labelKey)} + `; + } + + _renderHome() { + return html` + this._togglePage('home', e)}> + + ${t('nav.chat')} + `; + } + + _renderInbox(item) { + return html` + this._togglePage('inbox', e)}> + + + ${t('nav.inbox')} + ${this._inboxCount > 0 + ? html`${this._inboxCount}` + : ''} + + `; + } + + _renderPluginEntry(p) { + const route = `plugin/${p.plugin_id}/${p.page_id}`; + return html` + this._togglePage(route, e)}> + + ${p.title} + `; + } + _renderTasksMenu() { const active = this._activePage === 'tasks'; const sec = this._tasksSection; @@ -235,23 +413,6 @@ export class AppSidebar extends I18nMixin(LightElement) { `; } - _renderPluginPages() { - if (!this._pluginPages.length) return nothing; - return html` - - ${this._pluginPages.map(p => { - const route = `plugin/${p.plugin_id}/${p.page_id}`; - return html` - this._togglePage(route, e)}> - - ${p.title} - `; - })} - `; - } - _renderRecentProjects() { if (!this._recentProjects.length) return nothing; return html` @@ -273,9 +434,10 @@ export class AppSidebar extends I18nMixin(LightElement) { } render() { - // Simplified interface (role attrs `ui_mode: "simple"`): chat + inbox only. - // Hiding links is not access control — every route stays capability-gated - // server-side; this only shapes the navigation for less technical members. + // Simplified interface (role attrs `ui_mode: "simple"`): chat + inbox only, + // ungrouped. Hiding links is not access control — every route stays + // capability-gated server-side; this only shapes the nav for less technical + // members. const simple = this._me?.ui_mode === 'simple'; return html` - ${this._renderTabBar()} - ${this._error ? html`
${this._error}
` : nothing} - ${this._activeTab === 'tickets' ? this._renderTicketsTab() : nothing} - - ${this._modal ? this._renderModal() : nothing} +
+ ${this._project.description + ? html`

${this._project.description}

` + : nothing} + ${this._renderFilesPanel()} + ${this._renderSharePanel()} +
`; } diff --git a/web/components/projects/project-list.js b/web/components/projects/project-list.js index 54c95fc..f900935 100644 --- a/web/components/projects/project-list.js +++ b/web/components/projects/project-list.js @@ -33,7 +33,7 @@ export class ProjectListSection extends LightElement { } _emptyForm() { - return { name: '', path: '', description: '' }; + return { name: '', description: '' }; } async load() { @@ -54,7 +54,7 @@ export class ProjectListSection extends LightElement { } _openEdit(project) { - this._form = { name: project.name, path: project.path, description: project.description ?? '' }; + this._form = { name: project.name, description: project.description ?? '' }; this._error = null; this._modal = { mode: 'edit', project }; } @@ -135,13 +135,6 @@ export class ProjectListSection extends LightElement { .value=${this._form.name} @input=${e => this._setField('name', e.target.value)} /> -
- - this._setField('path', e.target.value)} /> -