feat(mcp): WhatsApp connector, archivable catalog, MCP connector config endpoint

This commit is contained in:
2026-07-19 10:55:44 +01:00
parent ffea3f41f9
commit f85876350e
23 changed files with 1324 additions and 3614 deletions
+15 -2
View File
@@ -151,7 +151,11 @@ MCP servers are surfaced to users as **"Connectors"** (UI naming; `mcp`/schema s
**Tables** (see DB section) — registry: `mcp_catalog` (admin-vetted templates; holds only the *schema* of what an activation must supply, never live creds — plus, for OAuth, `oauth_provider` + `oauth_scopes_json` + `deliver_json`), `mcp_global_servers` + `mcp_global_access`, `oauth_providers` (per-provider client creds), `role_capabilities`. Owner: `mcp_user_servers` (per-user activations; `api_key` encrypted at rest — the refresh token for an OAuth one — `catalog_name`/`oauth_provider`/`deliver_json` bare `TEXT` snapshots). **Tables** (see DB section) — registry: `mcp_catalog` (admin-vetted templates; holds only the *schema* of what an activation must supply, never live creds — plus, for OAuth, `oauth_provider` + `oauth_scopes_json` + `deliver_json`), `mcp_global_servers` + `mcp_global_access`, `oauth_providers` (per-provider client creds), `role_capabilities`. Owner: `mcp_user_servers` (per-user activations; `api_key` encrypted at rest — the refresh token for an OAuth one — `catalog_name`/`oauth_provider`/`deliver_json` bare `TEXT` snapshots).
**Endpoints** (`src/frontend/api/mcp.rs`, mounted in `api/mod.rs`) — admin: `/mcp/catalog` (GET/POST/DELETE), `/mcp/global` (list/enable/delete + `/{id}/access` GET/PUT), `/mcp/providers` (GET/POST + DELETE `/{name}` — OAuth provider creds, secret never returned to the browser). User: `/mcp/available`, `/mcp/activate`, `/mcp/activated` (+ DELETE `/{id}` to deactivate), `/mcp/oauth/start` + `/mcp/oauth/complete` (the §15 login). `connectors.js` (`<connectors-page>`) renders the user view (activate/deactivate + granted globals) always, plus the admin view (catalog + global + per-server access + a **Sign-in providers** modal) when `role_id === 'admin'`; `connector-detail.js` (`<connector-detail-page>`) is a connector's own page and hosts the OAuth login panel. **Endpoints** (`src/frontend/api/mcp.rs`, mounted in `api/mod.rs`) — admin: `/mcp/catalog` (GET/POST/DELETE), `/mcp/global` (list/enable/delete + `/{id}/access` GET/PUT), `/mcp/providers` (GET/POST + DELETE `/{name}` — OAuth provider creds, secret never returned to the browser). User: `/mcp/available`, `/mcp/activate`, `/mcp/activated` (+ DELETE `/{id}` to deactivate), `/mcp/oauth/start` + `/mcp/oauth/complete` (the §15 OAuth login), `/mcp/login/status` + `/mcp/login/reset` (the §15 QR/device login — see below). `connectors.js` (`<connectors-page>`) renders the user view (activate/deactivate + granted globals) always, plus the admin view (catalog + global + per-server access + a **Sign-in providers** modal) when `role_id === 'admin'`; `connector-detail.js` (`<connector-detail-page>`) is a connector's own page and hosts both the OAuth login panel and the QR login panel.
**Dependency reconciler (`mcp::install::ensure_installed`).** Copying a local-script connector's files into a container never installed its deps. `ensure_installed` closes that: a **content-hash reconciler** keyed on the connector's *source* files (not a version string) that, when the hash changed, re-copies the files and installs deps inside the container — `npm ci --omit=dev` (node, from `package.json`) and/or `pip install --target .pydeps` (python, from `requirements.txt`, put on the server's `PYTHONPATH` by `user_row_spec`). Runs at activation **and** on every per-user startup path (`UserContext` build, remount) via `mcp::prepare_local_connector`, so a fresh container installs from scratch, an updated connector re-installs, and an unchanged one is a hash-match no-op. Deps are therefore **never vendored** — connectors ship `package.json`/`requirements.txt`, not `node_modules/`. Authoring contract for connectors lives in `scripts/CONNECTOR_MANIFEST_GUIDE.md`.
**Connector versioning.** `mcp_catalog` carries `version` (INTEGER — the update-comparison key), `version_string` (semver, display) and `version_release_date` (ISO, display), snapshotted from the feed on install. The marketplace list computes `update_available` = feed `version` > installed `version` (strict) and surfaces it as an "Update" button (`marketplace.js`). The integer is the UI signal; the actual re-install trigger is the reconciler's content-hash.
### OAuth per-user connectors (blueprint §15 — copy-paste flow) ### OAuth per-user connectors (blueprint §15 — copy-paste flow)
@@ -162,7 +166,16 @@ OAuth2 authorization-code + PKCE is wired for per-user connectors (Gmail is the
- **Credential delivery = env, nothing on disk.** The manifest's `deliver` (`{as,format,env}`, parsed as `mcp::DeliverSpec`) says how the token reaches the server. `user_row_spec_resolved` assembles the credential (`google_authorized_user` JSON = client creds from the provider + refresh token) and injects it as an env var (`GMAIL_CREDS_JSON`) on the `docker exec` — never a file, coherent with §2 (the tempted admin doesn't read `/proc`). The server reads it via `Credentials.from_authorized_user_info`. Ran both at OAuth-complete and at login-time per-user startup. - **Credential delivery = env, nothing on disk.** The manifest's `deliver` (`{as,format,env}`, parsed as `mcp::DeliverSpec`) says how the token reaches the server. `user_row_spec_resolved` assembles the credential (`google_authorized_user` JSON = client creds from the provider + refresh token) and injects it as an env var (`GMAIL_CREDS_JSON`) on the `docker exec` — never a file, coherent with §2 (the tempted admin doesn't read `/proc`). The server reads it via `Credentials.from_authorized_user_info`. Ran both at OAuth-complete and at login-time per-user startup.
- **Google needs a Web-application client**: a Desktop client rejects an `https://` redirect (loopback only), so the `oauth/show.html` redirect must be registered on a **Web app** OAuth client, and exact-match under Authorized redirect URIs — `redirect_uri_mismatch` otherwise. - **Google needs a Web-application client**: a Desktop client rejects an `https://` redirect (loopback only), so the `oauth/show.html` redirect must be registered on a **Web app** OAuth client, and exact-match under Authorized redirect URIs — `redirect_uri_mismatch` otherwise.
**Deferred:** the other §15 interactive kinds (QR / SSH via elicitation) — `deliver.as=file` and non-Google providers are unimplemented paths that error clearly rather than half-work. No boot seed of catalog presets; the admin populates the catalog from the Marketplace. ### QR / interactive device login (blueprint §15 — polling flow)
For a per-user connector whose credential is produced by **pairing** (`auth.type: "qr"`; WhatsApp is the first, on Baileys — the slim `skald-runtime` image has no Chromium, so a browser-based client is out), there is no code to paste and the server must **run** to produce the QR. The seam is a generic tool contract, reusable for future device kinds (SSH…):
- **`login_status` tool contract.** A connector needing an interactive login exposes one tool, `login_status`, returning JSON `{state, qr?, message}` (state: `connecting|need_scan|ready|logged_out`; `qr` is a data-URL PNG only while `need_scan`). Skald calls it **directly, never the agent**.
- **Flow.** `activate` on a `qr` entry inserts a **pending** `mcp_user_servers` row and **starts** the server (unlike OAuth, which defers), returning `needs_login`/`login_kind:"qr"`. `/mcp/login/status` ensures the server is running (restarts a pending one), calls `login_status`, and returns its state; on `ready` it flips the row's `auth_state` so `all_startable` picks it up next login. `/mcp/login/reset` calls the connector's `logout` tool to re-arm (link a different device). The `connector-detail.js` QR panel polls `login/status` and renders the QR.
- **Credential = on-disk session, not a token.** The connector persists its session inside its own dir (e.g. `./auth/`), under the bind-mounted home so it survives a container recreate — the honest §4 gap (admin-root-readable), not `memory_docs`.
- **Node 18 gotcha**: the container ships Node 18; Baileys uses the Web Crypto global, so the server must `globalThis.crypto ??= require('crypto').webcrypto` or it dies pre-QR with "crypto is not defined".
**Deferred:** SSH and other §15 device kinds (would reuse the `login_status` contract), `deliver.as=file`, and non-Google OAuth providers are unimplemented paths that error clearly rather than half-work. No boot seed of catalog presets; the admin populates the catalog from the Marketplace.
## Multimodal attachments ## Multimodal attachments
+20
View File
@@ -1,12 +1,32 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// How a config property is rendered and edited in the Config UI.
///
/// Beyond the plain scalars (`String`/`Int`/`Bool`, rendered as text/number/
/// switch), a variant can stand for a **custom, higher-level control** whose
/// allowed values are computed by the backend rather than typed by hand —
/// `SecurityGroup` and `Locale` are both of this kind: they turn into a
/// dropdown fed by a server-supplied `options` list.
///
/// **Adding your own is cheap and encouraged.** If a new config section would
/// otherwise expose a free-text field where only a fixed/derived set of values
/// is valid, prefer adding a variant here instead. The wiring is three small,
/// symmetric edits:
/// 1. add the variant below;
/// 2. in `frontend/api/config.rs`, map it to a type string and (if it's a
/// dropdown) build its `options: Vec<SelectOption>`;
/// 3. in `web/components/config-page.js`, add a render branch for that type.
/// Anything carrying `options` renders as a `<select>` — see `_renderInput`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum PropertyType { pub enum PropertyType {
String, String,
Int, Int,
Bool, Bool,
/// Dropdown of the instance's security groups (run-context groups).
SecurityGroup, SecurityGroup,
/// Dropdown of the interface languages the instance supports.
Locale,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
+26 -4
View File
@@ -54,6 +54,13 @@ pub struct McpCatalogRow {
pub icon_large_path: Option<String>, pub icon_large_path: Option<String>,
pub friendly_name: Option<String>, pub friendly_name: Option<String>,
pub description: Option<String>, pub description: Option<String>,
/// Marketplace build number — the **comparison key** for updates (a feed entry
/// with a higher `version` than this installed one is "update available").
/// Monotonic per connector; `version_string`/`version_release_date` are display
/// only. NULL for a pre-versioning or manually-added entry.
pub version: Option<i64>,
pub version_string: Option<String>,
pub version_release_date: Option<String>,
pub created_at: String, pub created_at: String,
} }
@@ -98,7 +105,7 @@ const SELECT: &str =
script_path, config_schema_json, auth_kind, oauth_provider, oauth_scopes_json, \ script_path, config_schema_json, auth_kind, oauth_provider, oauth_scopes_json, \
deliver_json, role_filter, verify_command, \ deliver_json, role_filter, verify_command, \
verify_script_path, icon_small_path, icon_large_path, friendly_name, \ verify_script_path, icon_small_path, icon_large_path, friendly_name, \
description, created_at \ description, version, version_string, version_release_date, created_at \
FROM mcp_catalog"; FROM mcp_catalog";
// ── Reads ──────────────────────────────────────────────────────────────────── // ── Reads ────────────────────────────────────────────────────────────────────
@@ -160,6 +167,11 @@ pub struct UpsertCatalog<'a> {
pub icon_large_path: Option<&'a str>, pub icon_large_path: Option<&'a str>,
pub friendly_name: Option<&'a str>, pub friendly_name: Option<&'a str>,
pub description: Option<&'a str>, pub description: Option<&'a str>,
/// Versioning (from the feed). All three `None` for the admin's manual form,
/// which COALESCEs them away rather than blanking an installed entry's version.
pub version: Option<i64>,
pub version_string: Option<&'a str>,
pub version_release_date: Option<&'a str>,
} }
pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> { pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
@@ -168,8 +180,9 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
(name, scope, source, transport, command, args_json, env_json, url, (name, scope, source, transport, command, args_json, env_json, url,
script_path, config_schema_json, auth_kind, oauth_provider, oauth_scopes_json, script_path, config_schema_json, auth_kind, oauth_provider, oauth_scopes_json,
deliver_json, role_filter, verify_command, deliver_json, role_filter, verify_command,
verify_script_path, icon_small_path, icon_large_path, friendly_name, description) verify_script_path, icon_small_path, icon_large_path, friendly_name, description,
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21) version, version_string, version_release_date)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)
ON CONFLICT(name) DO UPDATE SET ON CONFLICT(name) DO UPDATE SET
scope = excluded.scope, scope = excluded.scope,
source = excluded.source, source = excluded.source,
@@ -194,7 +207,13 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
icon_small_path = COALESCE(excluded.icon_small_path, mcp_catalog.icon_small_path), icon_small_path = COALESCE(excluded.icon_small_path, mcp_catalog.icon_small_path),
icon_large_path = COALESCE(excluded.icon_large_path, mcp_catalog.icon_large_path), icon_large_path = COALESCE(excluded.icon_large_path, mcp_catalog.icon_large_path),
friendly_name = excluded.friendly_name, friendly_name = excluded.friendly_name,
description = excluded.description description = excluded.description,
-- Version fields come from the feed on (re)install; the admin's manual
-- form passes NULL, so COALESCE keeps the installed version rather than
-- wiping it (same rationale as icons above).
version = COALESCE(excluded.version, mcp_catalog.version),
version_string = COALESCE(excluded.version_string, mcp_catalog.version_string),
version_release_date = COALESCE(excluded.version_release_date, mcp_catalog.version_release_date)
RETURNING id", RETURNING id",
) )
.bind(e.name) .bind(e.name)
@@ -218,6 +237,9 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
.bind(e.icon_large_path) .bind(e.icon_large_path)
.bind(e.friendly_name) .bind(e.friendly_name)
.bind(e.description) .bind(e.description)
.bind(e.version)
.bind(e.version_string)
.bind(e.version_release_date)
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
Ok(row.0) Ok(row.0)
+8
View File
@@ -488,6 +488,9 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
icon_large_path TEXT, icon_large_path TEXT,
friendly_name TEXT, friendly_name TEXT,
description TEXT, description TEXT,
version INTEGER, -- marketplace build number: the update-comparison key
version_string TEXT, -- semver, display only
version_release_date TEXT, -- ISO date, display only
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
)", )",
) )
@@ -497,6 +500,11 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
ensure_column(pool, "mcp_catalog", "oauth_provider", "TEXT").await?; ensure_column(pool, "mcp_catalog", "oauth_provider", "TEXT").await?;
ensure_column(pool, "mcp_catalog", "oauth_scopes_json", "TEXT").await?; ensure_column(pool, "mcp_catalog", "oauth_scopes_json", "TEXT").await?;
ensure_column(pool, "mcp_catalog", "deliver_json", "TEXT").await?; ensure_column(pool, "mcp_catalog", "deliver_json", "TEXT").await?;
// Versioning columns are additive: the installed `version` integer is compared
// against the feed's to surface "update available" in the marketplace UI.
ensure_column(pool, "mcp_catalog", "version", "INTEGER").await?;
ensure_column(pool, "mcp_catalog", "version_string", "TEXT").await?;
ensure_column(pool, "mcp_catalog", "version_release_date", "TEXT").await?;
// Concrete globally-active connectors (shared, stateless — web-search etc.). // Concrete globally-active connectors (shared, stateless — web-search etc.).
// They run on the HOST. The global secret (admin's API key) is fine here: // They run on the HOST. The global secret (admin's API key) is fine here:
+18 -2
View File
@@ -54,6 +54,20 @@ pub fn language_name(locale: &str) -> String {
} }
} }
/// Native (endonym) language name for UI language pickers (`"it"` → `"Italiano"`).
/// Unlike [`language_name`] — an English exonym for prompt rendering — this is
/// what a user expects to see when *choosing* their language, and it reads the
/// same regardless of the interface language currently active. Unknown codes
/// pass through unchanged.
pub fn native_language_name(locale: &str) -> String {
match locale {
"en" => "English".into(),
"it" => "Italiano".into(),
"fr" => "Français".into(),
other => other.into(),
}
}
/// Writes the instance default locale straight to the registry `config` table. /// Writes the instance default locale straight to the registry `config` table.
/// Used by first-run provisioning shells (e.g. `skald-setup`), where no /// Used by first-run provisioning shells (e.g. `skald-setup`), where no
/// `GlobalConfigManager` — hence no system bus — exists. A running server /// `GlobalConfigManager` — hence no system bus — exists. A running server
@@ -82,8 +96,10 @@ pub fn config_set() -> ConfigSet {
ConfigProperty { ConfigProperty {
key: DEFAULT_LOCALE_KEY.into(), key: DEFAULT_LOCALE_KEY.into(),
name: "Language".into(), name: "Language".into(),
description: "Default interface language for the whole instance (e.g. en, it). Each user can override it on their profile.".into(), description: "Default interface language for the whole instance. Each user can override it on their profile.".into(),
property_type: PropertyType::String, // A dropdown of `SUPPORTED_LOCALES` rather than a free-text box:
// the valid values are a fixed set the backend already owns.
property_type: PropertyType::Locale,
default_value: Some("en".into()), default_value: Some("en".into()),
}, },
], ],
+172
View File
@@ -20,8 +20,10 @@
//! diverges the moment the admin edits the catalog row. //! diverges the moment the admin edits the catalog row.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use sha2::{Digest, Sha256};
use crate::container::{CONTAINER_HOME, HOMES_DIR}; use crate::container::{CONTAINER_HOME, HOMES_DIR};
@@ -31,6 +33,23 @@ pub const CONNECTORS_DIR: &str = "connectors";
/// The manifest, saved verbatim at install time as provenance (never read back). /// The manifest, saved verbatim at install time as provenance (never read back).
pub const MANIFEST_FILE: &str = "connector.json"; pub const MANIFEST_FILE: &str = "connector.json";
/// Marker file in a user's installed connector dir recording the content hash of
/// the source folder that produced the current files + dependencies. When the
/// source changes (a marketplace update) this hash changes, and the reconciler
/// re-copies + re-installs; when it matches, a startup is a cheap no-op.
const INSTALL_LOCK: &str = ".skald-install.lock";
/// Where `pip install --target` lands a python connector's dependencies, a sibling
/// of the server file inside the connector dir. Injected onto the server process's
/// `PYTHONPATH` at spec-build time (see `mcp::user_row_spec`). Node needs no
/// equivalent: `node_modules/` beside the entry file is resolved automatically.
pub const PYDEPS_DIR: &str = ".pydeps";
/// Ceiling for a single `npm`/`pip` install inside the container. Baileys or a
/// heavy python wheel set can take a while on a cold cache; a genuinely stuck
/// install must still fail rather than hang a login forever.
const DEPS_INSTALL_TIMEOUT_SECS: u64 = 300;
/// Where a per-user connector's files land inside the container, under the home /// Where a per-user connector's files land inside the container, under the home
/// mount. `{CONTAINER_HOME}/.skald/mcp/<runtime_name>/`. /// mount. `{CONTAINER_HOME}/.skald/mcp/<runtime_name>/`.
const IN_CONTAINER_MCP_SUBDIR: &str = ".skald/mcp"; const IN_CONTAINER_MCP_SUBDIR: &str = ".skald/mcp";
@@ -142,6 +161,159 @@ fn copy_runtime_files(src: &Path, dest: &Path, rel: &Path) -> Result<()> {
Ok(()) Ok(())
} }
// ── dependency reconciler (blueprint §6/§7) ─────────────────────────────────────
//
// Copying a connector's files into a container never made its dependencies exist
// there: a python server still needs its wheels, a node server its `node_modules`.
// [`ensure_installed`] closes that gap and keeps it closed across updates.
//
// It is a **content-hash reconciler**, not a one-shot installer. The trigger is a
// hash of the *source* folder's runtime files, not a version string the author
// might forget to bump: any real change to what the connector ships (including its
// `package.json` / `requirements.txt`) changes the hash and forces a refresh. It
// runs on every per-user startup path (first login, container recreate/remount)
// and at activation, so:
// - a brand-new container (no home files) installs from scratch,
// - an updated connector (source changed) re-copies + re-installs,
// - an unchanged one (hash matches the lock) is skipped in microseconds.
/// Reconciles user `user_id`'s copy of local-script connector `folder` (runtime
/// name `runtime_name`) inside `container`: refreshes the files when the source
/// changed, then (re)installs node and/or python dependencies. Idempotent and
/// hash-guarded. Dependency install is best-effort at the call site (a failure is
/// returned, and callers log-and-continue so the server still starts and surfaces
/// its own import error) — but a changed source with a failed install does NOT
/// write the lock, so the next startup retries.
pub async fn ensure_installed(
user_id: &str,
runtime_name: &str,
folder: &str,
container: &str,
) -> Result<()> {
let src = connector_dir(folder)?;
if !src.is_dir() {
// Nothing shipped for this connector on this box; leave any existing files
// in place (a caller that truly needs them says so with its own message).
return Ok(());
}
let home = home_dir_for(user_id, runtime_name)?;
let lock = home.join(INSTALL_LOCK);
let src_hash = hash_source(&src)?;
if let Ok(prev) = std::fs::read_to_string(&lock) {
if prev.trim() == src_hash {
return Ok(()); // files + deps already current
}
}
// (Re)copy source files. `install_into_home` overwrites shipped files but never
// deletes others, so the durable `auth/`, `node_modules/`, `.pydeps/` and the
// lock itself survive an update.
let container_dir = match install_into_home(user_id, runtime_name, folder)? {
Some(d) => d,
None => return Ok(()),
};
// Install whatever ecosystem the connector ships. A connector may ship both.
if home.join("package.json").is_file() {
run_in_container(
container,
&container_dir,
// `npm ci` is reproducible when a lockfile is present; fall back to
// `npm install` when it is not (or when ci rejects a drifted lock).
"npm ci --omit=dev --no-audit --no-fund 2>&1 || npm install --omit=dev --no-audit --no-fund 2>&1",
"npm",
)
.await?;
}
if home.join("requirements.txt").is_file() {
run_in_container(
container,
&container_dir,
// `--target .pydeps` keeps deps beside the server (durable, per-connector)
// and out of the PEP-668 externally-managed system site; `--break-system-
// packages` silences that guard even though `--target` already avoids it.
&format!(
"python3 -m pip install --break-system-packages --target {PYDEPS_DIR} \
-r requirements.txt 2>&1"
),
"pip",
)
.await?;
}
std::fs::write(&lock, &src_hash)
.with_context(|| format!("failed to write {}", lock.display()))?;
Ok(())
}
/// A deterministic content hash of a connector's **runtime** files (host assets —
/// icons, `connector.json` — excluded, since they never reach the container and so
/// cannot change what runs). Path + bytes of every file, sorted, folded into one
/// SHA-256. Two installs of the same source produce the same hash on any box.
fn hash_source(src: &Path) -> Result<String> {
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
collect_source_files(src, Path::new(""), &mut files)?;
files.sort_by(|a, b| a.0.cmp(&b.0));
let mut h = Sha256::new();
for (rel, bytes) in files {
h.update(rel.as_bytes());
h.update([0u8]);
h.update(&bytes);
h.update([0u8]);
}
Ok(h.finalize().iter().fold(String::with_capacity(64), |mut s, b| {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
s
}))
}
fn collect_source_files(dir: &Path, rel: &Path, out: &mut Vec<(String, Vec<u8>)>) -> Result<()> {
for entry in std::fs::read_dir(dir).with_context(|| format!("cannot read {}", dir.display()))? {
let entry = entry?;
let child_rel = rel.join(entry.file_name());
if entry.file_type()?.is_dir() {
collect_source_files(&entry.path(), &child_rel, out)?;
continue;
}
let rel_str = child_rel.to_string_lossy().to_string();
if is_host_asset(&rel_str) {
continue;
}
let bytes = std::fs::read(entry.path())
.with_context(|| format!("cannot read {}", entry.path().display()))?;
out.push((rel_str, bytes));
}
Ok(())
}
/// Runs a shell `script` inside `container` at `workdir` via `docker exec`, under a
/// timeout, and fails with the tail of the output on a non-zero exit. Output is not
/// captured into the DB — only surfaced in the returned error for the caller's log.
async fn run_in_container(container: &str, workdir: &Path, script: &str, label: &str) -> Result<()> {
let output = tokio::time::timeout(
Duration::from_secs(DEPS_INSTALL_TIMEOUT_SECS),
tokio::process::Command::new("docker")
.arg("exec")
.arg("-w").arg(workdir)
.arg(container)
.arg("sh").arg("-c").arg(script)
.output(),
)
.await
.map_err(|_| anyhow::anyhow!("{label} install timed out after {DEPS_INSTALL_TIMEOUT_SECS}s"))?
.with_context(|| format!("failed to run docker exec for {label} install"))?;
if !output.status.success() {
let mut combined = String::from_utf8_lossy(&output.stdout).to_string();
combined.push_str(&String::from_utf8_lossy(&output.stderr));
let tail: String = combined.lines().rev().take(12).collect::<Vec<_>>()
.into_iter().rev().collect::<Vec<_>>().join("\n");
bail!("{label} install failed:\n{tail}");
}
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+63 -1
View File
@@ -279,6 +279,13 @@ impl McpManager {
self.descriptions.write().unwrap().clear(); self.descriptions.write().unwrap().clear();
} }
/// Whether a server by this name currently has a live connection in the
/// runtime. Used by the interactive-login API to decide whether it must
/// (re)start a pending connector before polling its `login_status`.
pub fn is_running(&self, name: &str) -> bool {
self.servers.read().unwrap().contains_key(name)
}
pub fn tools(&self) -> Vec<McpTool> { pub fn tools(&self) -> Vec<McpTool> {
self.servers.read().unwrap().values() self.servers.read().unwrap().values()
.flat_map(|s| s.tools().iter().cloned()) .flat_map(|s| s.tools().iter().cloned())
@@ -518,6 +525,53 @@ pub fn global_row_spec(row: &crate::db::mcp_global_servers::McpGlobalServerRow)
} }
} }
/// The `PYTHONPATH` to hand a python connector so it imports the deps installed
/// under `<connector-dir>/.pydeps`. `None` for anything that is not a python
/// command, or a python one with no script argument to derive the dir from.
fn python_pydeps_path(command: Option<&str>, args: &[String]) -> Option<String> {
let cmd = command?;
let base = std::path::Path::new(cmd).file_name().and_then(|s| s.to_str()).unwrap_or(cmd);
if !base.starts_with("python") {
return None;
}
let script = args.first()?;
let dir = std::path::Path::new(script).parent()?;
Some(dir.join(install::PYDEPS_DIR).to_string_lossy().into_owned())
}
/// Reconciles a per-user local-script connector's files + dependencies inside the
/// user's container before it is (re)started — see [`install::ensure_installed`].
/// Best-effort: logs and returns on any failure so a broken connector never blocks
/// the others from starting. A no-op for remote / self-registered rows (no vetted
/// catalog folder to install from).
pub async fn prepare_local_connector(
registry: &SqlitePool,
user_id: &str,
container: &str,
row: &crate::db::mcp_user_servers::McpUserServerRow,
) {
if row.source != "local_script" {
return;
}
let Some(catalog_name) = row.catalog_name.as_deref() else { return };
let folder = match crate::db::mcp_catalog::get_by_name(registry, catalog_name).await {
Ok(Some(entry)) => entry
.script_path
.as_deref()
.and_then(|sp| install::split_script_path(sp).ok())
.map(|(folder, _)| folder.to_string()),
Ok(None) => None,
Err(e) => {
warn!("connector '{}': catalog lookup failed: {e}", row.name);
None
}
};
let Some(folder) = folder else { return };
if let Err(e) = install::ensure_installed(user_id, &row.name, &folder, container).await {
warn!("connector '{}': dependency install failed: {e}", row.name);
}
}
/// Builds a spec for a user's per-user connector — container transport: a /// Builds a spec for a user's per-user connector — container transport: a
/// `local_script` (or any stdio server) runs INSIDE the user's container /// `local_script` (or any stdio server) runs INSIDE the user's container
/// (`launch_in = Some(container)`), against the script copied into the /// (`launch_in = Some(container)`), against the script copied into the
@@ -528,7 +582,15 @@ pub fn user_row_spec(
) -> McpServerSpec { ) -> McpServerSpec {
let transport = transport_of(&row.transport); let transport = transport_of(&row.transport);
let launch_in = matches!(transport, McpTransport::Stdio).then(|| container.to_string()); let launch_in = matches!(transport, McpTransport::Stdio).then(|| container.to_string());
let env = row.env(); let mut env = row.env();
// A python connector's deps are installed under `<dir>/.pydeps` (pip `--target`,
// see `install::ensure_installed`); point the interpreter at them. Node needs
// nothing — `node_modules/` beside the entry file resolves on its own. Setting
// it unconditionally for a python command is safe even before the first install:
// python silently ignores a non-existent `PYTHONPATH` entry.
if let Some(pp) = python_pydeps_path(row.command.as_deref(), &row.args()) {
env.entry("PYTHONPATH".to_string()).or_insert(pp);
}
let (url, api_key) = apply_key_placeholder(row.url.clone(), row.api_key.clone(), &env); let (url, api_key) = apply_key_placeholder(row.url.clone(), row.api_key.clone(), &env);
McpServerSpec { McpServerSpec {
config: McpServerConfig { config: McpServerConfig {
+4
View File
@@ -107,6 +107,10 @@ impl Skald {
let container = crate::container::container_name(user_id); let container = crate::container::container_name(user_id);
let mut specs = Vec::with_capacity(rows.len()); let mut specs = Vec::with_capacity(rows.len());
for r in &rows { for r in &rows {
// The home mount (and its `node_modules`/`.pydeps`) survives a
// recreate, so this is normally a hash-match no-op; it still covers
// the case where the source changed while the user was logged in.
crate::mcp::prepare_local_connector(self.db(), user_id, &container, r).await;
specs.push(crate::mcp::user_row_spec_resolved(r, &container, self.db()).await); specs.push(crate::mcp::user_row_spec_resolved(r, &container, self.db()).await);
} }
ctx.user_mcp.connect_all(specs, false).await; ctx.user_mcp.connect_all(specs, false).await;
@@ -205,12 +205,17 @@ impl UserContextFactory {
let upool = Arc::clone(&pool); let upool = Arc::clone(&pool);
let registry = Arc::clone(&self.registry_pool); let registry = Arc::clone(&self.registry_pool);
let container = crate::container::container_name(user_id); let container = crate::container::container_name(user_id);
let uid = user_id.to_string();
let mname: &'static str = Box::leak(format!("mcp:{user_id}").into_boxed_str()); let mname: &'static str = Box::leak(format!("mcp:{user_id}").into_boxed_str());
self.supervisor.adopt_one(mname, tokio::spawn(async move { self.supervisor.adopt_one(mname, tokio::spawn(async move {
match crate::db::mcp_user_servers::all_startable(&upool).await { match crate::db::mcp_user_servers::all_startable(&upool).await {
Ok(rows) => { Ok(rows) => {
let mut specs = Vec::with_capacity(rows.len()); let mut specs = Vec::with_capacity(rows.len());
for r in &rows { for r in &rows {
// Reconcile files + node/python deps in the container
// before starting (covers a fresh container and any
// connector update — see `prepare_local_connector`).
crate::mcp::prepare_local_connector(&registry, &uid, &container, r).await;
// OAuth connectors resolve their stored refresh token into // OAuth connectors resolve their stored refresh token into
// the env-delivered credential here (§15). // the env-delivered credential here (§15).
specs.push(crate::mcp::user_row_spec_resolved(r, &container, &registry).await); specs.push(crate::mcp::user_row_spec_resolved(r, &container, &registry).await);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8 -13
View File
@@ -1,19 +1,14 @@
{ {
"name": "whatsapp-mcp-server", "name": "skald-whatsapp-mcp",
"version": "1.0.0", "version": "2.0.0",
"description": "WhatsApp MCP server for skald (JSON-RPC 2.0 over stdio)", "private": true,
"description": "WhatsApp MCP connector for Skald (Baileys, no browser).",
"main": "index.js", "main": "index.js",
"scripts": { "engines": {
"start": "node index.js", "node": ">=18"
"install-deps": "npm install"
}, },
"dependencies": { "dependencies": {
"puppeteer": "^25.1.0", "@whiskeysockets/baileys": "^6.7.9",
"qrcode": "^1.5.4", "qrcode": "^1.5.4"
"qrcode-terminal": "^0.12.0",
"whatsapp-web.js": "^1.34.7"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+20 -3
View File
@@ -15,8 +15,12 @@ use super::ApiError;
// ── Response types ───────────────────────────────────────────────────────────── // ── Response types ─────────────────────────────────────────────────────────────
/// One choice in a dropdown-style property (see [`PropertyType`]). Deliberately
/// generic — `id` is the stored value, `name` the human label — so every custom
/// "pick from a fixed/derived set" property type reuses it (security groups,
/// locales, and whatever the next section needs).
#[derive(Serialize, Clone)] #[derive(Serialize, Clone)]
struct SecurityGroupOption { struct SelectOption {
id: String, id: String,
name: String, name: String,
} }
@@ -30,7 +34,7 @@ struct PropertyView {
value: Option<String>, value: Option<String>,
default_value: Option<String>, default_value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
options: Option<Vec<SecurityGroupOption>>, options: Option<Vec<SelectOption>>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -45,10 +49,21 @@ struct ConfigSetView {
pub async fn list_properties( pub async fn list_properties(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
// Option sources for the dropdown-style property types. Each custom
// `PropertyType` that renders as a `<select>` computes its choices here and
// ships them in `options`. To add a new one: build its `Vec<SelectOption>`
// and wire it into the `match` below (see `PropertyType` for the full
// three-step recipe).
let security_groups = skald.run_context_manager().list_groups().await let security_groups = skald.run_context_manager().list_groups().await
.unwrap_or_default() .unwrap_or_default()
.into_iter() .into_iter()
.map(|g| SecurityGroupOption { id: g.id, name: g.name }) .map(|g| SelectOption { id: g.id, name: g.name })
.collect::<Vec<_>>();
let locales = skald_core::i18n::SUPPORTED_LOCALES.iter()
.map(|code| SelectOption {
id: code.to_string(),
name: skald_core::i18n::native_language_name(code),
})
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut sets = Vec::with_capacity(skald.config_properties().len()); let mut sets = Vec::with_capacity(skald.config_properties().len());
@@ -56,11 +71,13 @@ pub async fn list_properties(
let mut props = Vec::with_capacity(set.properties.len()); let mut props = Vec::with_capacity(set.properties.len());
for prop in &set.properties { for prop in &set.properties {
let value = skald.config().get(&prop.key).await?; let value = skald.config().get(&prop.key).await?;
// Scalars carry no `options`; dropdown types attach their choices.
let (type_str, options) = match prop.property_type { let (type_str, options) = match prop.property_type {
PropertyType::Int => ("int", None), PropertyType::Int => ("int", None),
PropertyType::Bool => ("bool", None), PropertyType::Bool => ("bool", None),
PropertyType::String => ("string", None), PropertyType::String => ("string", None),
PropertyType::SecurityGroup => ("security_group", Some(security_groups.clone())), PropertyType::SecurityGroup => ("security_group", Some(security_groups.clone())),
PropertyType::Locale => ("locale", Some(locales.clone())),
}; };
props.push(PropertyView { props.push(PropertyView {
key: prop.key.clone(), key: prop.key.clone(),
+60 -8
View File
@@ -105,6 +105,28 @@ struct IndexEntry {
#[serde(default)] scope: Option<String>, #[serde(default)] scope: Option<String>,
/// `mcp_local` | `mcp_remote` — the §14 risk axis. /// `mcp_local` | `mcp_remote` — the §14 risk axis.
#[serde(default, rename = "type")] kind: Option<String>, #[serde(default, rename = "type")] kind: Option<String>,
/// Versioning (§ marketplace updates): `version` is the monotonic **integer**
/// build number — the comparison key for "update available". Tolerant of a
/// legacy string `version` during the schema migration (parsed to `None`).
#[serde(default, deserialize_with = "de_flexible_i64")] version: Option<i64>,
#[serde(default)] version_string: Option<String>,
#[serde(default)] version_release_date: Option<String>,
}
/// Deserializes an optional integer that may arrive as a JSON number or (during the
/// string-`version` → integer-`version` migration) as a numeric string. A
/// non-numeric string (`"2.0.1"`) yields `None` rather than a hard parse error, so
/// one un-migrated entry never fails the whole feed.
fn de_flexible_i64<'de, D>(d: D) -> Result<Option<i64>, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = Option::<serde_json::Value>::deserialize(d)?;
Ok(v.and_then(|v| match v {
serde_json::Value::Number(n) => n.as_i64(),
serde_json::Value::String(s) => s.trim().parse::<i64>().ok(),
_ => None,
}))
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -182,7 +204,11 @@ struct VerifySpec {
#[derive(Debug, Clone, Default, Deserialize)] #[derive(Debug, Clone, Default, Deserialize)]
struct Manifest { struct Manifest {
#[serde(default)] name: Option<String>, #[serde(default)] name: Option<String>,
#[serde(default)] version: Option<String>, /// The monotonic **integer** build number (see [`IndexEntry::version`]). Tolerant
/// of a legacy string during migration.
#[serde(default, deserialize_with = "de_flexible_i64")] version: Option<i64>,
#[serde(default)] version_string: Option<String>,
#[serde(default)] version_release_date: Option<String>,
#[serde(default, rename = "type")] kind: Option<String>, #[serde(default, rename = "type")] kind: Option<String>,
#[serde(default)] transport: Option<String>, #[serde(default)] transport: Option<String>,
#[serde(default)] requires: Vec<String>, #[serde(default)] requires: Vec<String>,
@@ -316,7 +342,14 @@ fn files_of<'a>(entry: &'a IndexEntry, manifest: &'a Manifest) -> &'a [FileEntry
pub struct MarketplaceCard { pub struct MarketplaceCard {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub version: Option<String>, /// The feed's build number (integer) and its display metadata.
pub version: Option<i64>,
pub version_string: Option<String>,
pub version_release_date: Option<String>,
/// The installed catalog row's build number, when installed. `update_available`
/// is `true` when the feed's `version` is strictly greater.
pub installed_version: Option<i64>,
pub update_available: bool,
/// `per_user` | `global` /// `per_user` | `global`
pub scope: String, pub scope: String,
/// `remote` | `local_script` /// `remote` | `local_script`
@@ -342,15 +375,25 @@ pub struct MarketplaceCard {
pub installed: bool, pub installed: bool,
} }
fn card_of(h: &Hydrated, installed: bool) -> MarketplaceCard { fn card_of(h: &Hydrated, installed: bool, installed_version: Option<i64>) -> MarketplaceCard {
let source = norm_source(&h.entry, &h.manifest); let source = norm_source(&h.entry, &h.manifest);
let doc = h.manifest.docs.first().cloned().unwrap_or_default(); let doc = h.manifest.docs.first().cloned().unwrap_or_default();
// Prefer the manifest's version trio, falling back to the index entry's.
let version = h.manifest.version.or(h.entry.version);
let version_string = h.manifest.version_string.clone().or_else(|| h.entry.version_string.clone());
let version_release_date = h.manifest.version_release_date.clone().or_else(|| h.entry.version_release_date.clone());
// "Update available" is a strict integer bump on an already-installed connector.
let update_available = matches!((version, installed_version), (Some(feed), Some(have)) if feed > have);
MarketplaceCard { MarketplaceCard {
id: h.entry.id.clone(), id: h.entry.id.clone(),
name: h.entry.name.clone() name: h.entry.name.clone()
.or_else(|| h.manifest.name.clone()) .or_else(|| h.manifest.name.clone())
.unwrap_or_else(|| h.entry.id.clone()), .unwrap_or_else(|| h.entry.id.clone()),
version: h.manifest.version.clone(), version,
version_string,
version_release_date,
installed_version,
update_available,
scope: norm_scope(&h.entry, &h.manifest), scope: norm_scope(&h.entry, &h.manifest),
transport: norm_transport(&h.manifest, &source), transport: norm_transport(&h.manifest, &source),
source, source,
@@ -507,14 +550,16 @@ pub async fn list(
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?; require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?;
let feed = feed(q.refresh).await?; let feed = feed(q.refresh).await?;
let installed: std::collections::HashSet<String> = mcp_catalog::list(skald.db()) // name → installed build number (present = installed; the value drives the
// "update available" comparison, `None` for a pre-versioning install).
let installed: std::collections::HashMap<String, Option<i64>> = mcp_catalog::list(skald.db())
.await? .await?
.into_iter() .into_iter()
.map(|r| r.name) .map(|r| (r.name, r.version))
.collect(); .collect();
let cards: Vec<MarketplaceCard> = feed let cards: Vec<MarketplaceCard> = feed
.iter() .iter()
.map(|h| card_of(h, installed.contains(&h.entry.id))) .map(|h| card_of(h, installed.contains_key(&h.entry.id), installed.get(&h.entry.id).copied().flatten()))
.collect(); .collect();
Ok(Json(json!({ "base_url": base_url(), "connectors": cards }))) Ok(Json(json!({ "base_url": base_url(), "connectors": cards })))
} }
@@ -717,6 +762,13 @@ pub async fn install(
.llm_short_description .llm_short_description
.as_deref() .as_deref()
.or(h.entry.user_description.as_deref()), .or(h.entry.user_description.as_deref()),
// Snapshot the feed's version so a later listing can compare it against a
// newer feed and surface "update available". Manifest wins over index.
version: h.manifest.version.or(h.entry.version),
version_string: h.manifest.version_string.as_deref()
.or(h.entry.version_string.as_deref()),
version_release_date: h.manifest.version_release_date.as_deref()
.or(h.entry.version_release_date.as_deref()),
}, },
) )
.await?; .await?;
@@ -1103,7 +1155,7 @@ mod tests {
assert!(!feed.is_empty(), "feed returned no connectors"); assert!(!feed.is_empty(), "feed returned no connectors");
for h in &feed { for h in &feed {
let c = card_of(h, false); let c = card_of(h, false, None);
println!( println!(
"{:<8} scope={:<8} source={:<12} transport={:<6} auth={:<7} files={}", "{:<8} scope={:<8} source={:<12} transport={:<6} auth={:<7} files={}",
c.id, c.scope, c.source, c.transport, c.auth_kind, c.file_count c.id, c.scope, c.source, c.transport, c.auth_kind, c.file_count
+148
View File
@@ -332,6 +332,11 @@ pub async fn catalog_upsert(
icon_large_path: None, icon_large_path: None,
friendly_name: body.friendly_name.as_deref(), friendly_name: body.friendly_name.as_deref(),
description: body.description.as_deref(), description: body.description.as_deref(),
// Versioning is the feed's to set (marketplace install); the manual form
// leaves it untouched (COALESCE in `upsert`).
version: None,
version_string: None,
version_release_date: None,
}).await?; }).await?;
Ok(Json(json!({ "id": id }))) Ok(Json(json!({ "id": id })))
} }
@@ -751,6 +756,22 @@ pub async fn activate(
.and_then(|e| serde_json::to_string(e).ok()) .and_then(|e| serde_json::to_string(e).ok())
.or_else(|| entry.env_json.clone()); .or_else(|| entry.env_json.clone());
// Reconcile node/python dependencies into the container before anything
// tries to run the server (verify, the QR login, or a first message).
// Blocking and one-time: the content-hash lock in `ensure_installed`
// makes every later activation/login a no-op. A hard failure here is a
// clear error rather than a connector that silently never starts.
if entry.source == "local_script" {
if let Some(script) = entry.script_path.as_deref() {
if let Ok((folder, _)) = skald_core::mcp::split_script_path(script) {
let container = skald_core::container::container_name(&auth.user_id);
skald_core::mcp::install::ensure_installed(&auth.user_id, &name, folder, &container)
.await
.map_err(|e| ApiError::bad_request(format!("dependency install failed: {e}")))?;
}
}
}
// OAuth connectors do NOT activate directly (§15): the refresh token // OAuth connectors do NOT activate directly (§15): the refresh token
// comes from an interactive consent, not from the activation form. We // comes from an interactive consent, not from the activation form. We
// persist a PENDING row (files installed, command wired) and hand off to // persist a PENDING row (files installed, command wired) and hand off to
@@ -794,6 +815,42 @@ pub async fn activate(
}))); })));
} }
// QR (and other interactive-login) connectors, e.g. WhatsApp: unlike
// OAuth there is no code to paste back — the server must RUN to produce
// the QR, and the credential is the on-disk session it persists after the
// scan. Insert a PENDING row, start the server so it emits a QR, and hand
// off to the login panel, which polls `/mcp/login/status` until it reports
// `ready` (flipping the row so `all_startable` picks it up next login).
if entry.auth_kind == "qr" {
let id = mcp_user_servers::insert(&ctx.pool, mcp_user_servers::InsertUserServer {
name: &name,
catalog_name: Some(&entry.name),
source: &entry.source,
transport: &entry.transport,
command: command.as_deref(),
args_json,
env_json,
url: entry.url.as_deref(),
api_key: None, // the "credential" is the on-disk session
oauth_provider: None,
deliver_json: None,
script_rel_path: script_rel_path.as_deref(),
verify_command: None,
verify_script_rel_path: None,
auth_state: "pending",
}).await?;
if let Some(row) = mcp_user_servers::get(&ctx.pool, id).await? {
let container = skald_core::container::container_name(&auth.user_id);
let spec = skald_core::mcp::user_row_spec_resolved(&row, &container, skald.db()).await;
// The QR only appears once the socket connects; ignore a start
// error here — the login panel surfaces the real state via polling.
let _ = ctx.user_mcp.start_server(spec).await;
}
return Ok(Json(json!({
"id": id, "auth_state": "pending", "needs_login": true, "login_kind": "qr",
})));
}
mcp_user_servers::insert(&ctx.pool, mcp_user_servers::InsertUserServer { mcp_user_servers::insert(&ctx.pool, mcp_user_servers::InsertUserServer {
name: &name, name: &name,
catalog_name: Some(&entry.name), catalog_name: Some(&entry.name),
@@ -1045,3 +1102,94 @@ pub async fn oauth_complete(
Err(e) => Ok(Json(json!({ "id": row.id, "error": e.to_string(), "auth_state": "ready" }))), Err(e) => Ok(Json(json!({ "id": row.id, "error": e.to_string(), "auth_state": "ready" }))),
} }
} }
// ── user: interactive QR / device login for a per-user connector (§15) ─────────
//
// The generic seam for any connector whose login is neither an api-key nor an
// OAuth code-paste (WhatsApp's QR today; SSH / other device pairings later): the
// connector's server exposes a standard `login_status` tool returning
// `{state, qr?, message}`, and Skald calls it DIRECTLY (never the agent). Unlike
// OAuth, the server must be RUNNING to produce the credential (a QR the user
// scans), and the credential is the on-disk session it persists — so there is
// nothing to paste back, only a state to poll until it reports `ready`.
#[derive(Deserialize)]
pub struct LoginBody {
/// The pending `mcp_user_servers` row to sign in.
pub server_id: i64,
}
/// Starts `row`'s server in the user's runtime if it is not already live —
/// reconciling its deps first (a container recreated since activation may lack
/// them). Idempotent: a no-op when the server is already connected.
async fn ensure_user_server_running(
skald: &Skald,
ctx: &skald_core::skald::UserContext,
user_id: &str,
row: &mcp_user_servers::McpUserServerRow,
) -> Result<(), ApiError> {
if ctx.user_mcp.is_running(&row.name) {
return Ok(());
}
let container = skald_core::container::container_name(user_id);
skald_core::mcp::prepare_local_connector(skald.db(), user_id, &container, row).await;
let spec = skald_core::mcp::user_row_spec_resolved(row, &container, skald.db()).await;
ctx.user_mcp.start_server(spec).await
.map_err(|e| ApiError::bad_request(format!("could not start the connector: {e}")))?;
Ok(())
}
/// `POST /api/mcp/login/status` — polls a connector's interactive-login state.
/// Ensures the server is running, calls its `login_status` tool, and returns the
/// `{state, qr, message}` it reports (with `id`/`auth_state`). When the connector
/// reports `ready`, its row is flipped so `all_startable` starts it on the next
/// login. Safe to poll on an interval from the login panel.
pub async fn login_status(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Json(body): Json<LoginBody>,
) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let row = mcp_user_servers::get(&ctx.pool, body.server_id).await?
.ok_or_else(|| ApiError::not_found("no such connector"))?;
ensure_user_server_running(&skald, &ctx, &auth.user_id, &row).await?;
let result = ctx.user_mcp.call(&row.name, "login_status", json!({})).await
.map_err(|e| ApiError::bad_request(format!(
"this connector has no interactive login (no login_status tool): {e}"
)))?;
// The tool returns a JSON string in a text part; fall back to a plain message
// if a connector ever returns something else.
let wire = result.to_wire();
let mut v: Value = serde_json::from_str(&wire)
.unwrap_or_else(|_| json!({ "state": "connecting", "message": wire }));
let state = v.get("state").and_then(|s| s.as_str()).unwrap_or("connecting").to_string();
if state == "ready" && row.auth_state != "ready" {
mcp_user_servers::set_auth_state(&ctx.pool, row.id, "ready").await?;
}
if let Value::Object(ref mut m) = v {
m.insert("id".into(), json!(row.id));
m.insert("auth_state".into(), json!(if state == "ready" { "ready" } else { "pending" }));
}
Ok(Json(v))
}
/// `POST /api/mcp/login/reset` — re-arm the login (e.g. link a different phone).
/// Calls the connector's `logout` tool to clear the on-disk session and force a
/// fresh QR, and marks the row pending again.
pub async fn login_reset(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Json(body): Json<LoginBody>,
) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let row = mcp_user_servers::get(&ctx.pool, body.server_id).await?
.ok_or_else(|| ApiError::not_found("no such connector"))?;
ensure_user_server_running(&skald, &ctx, &auth.user_id, &row).await?;
let _ = ctx.user_mcp.call(&row.name, "logout", json!({})).await;
if row.auth_state == "ready" {
mcp_user_servers::set_auth_state(&ctx.pool, row.id, "pending").await?;
}
Ok(Json(json!({ "ok": true, "id": row.id, "auth_state": "pending" })))
}
+3
View File
@@ -158,6 +158,9 @@ pub fn router() -> Router<Arc<Skald>> {
// user: interactive OAuth login for a pending per-user connector (§15) // user: interactive OAuth login for a pending per-user connector (§15)
.route("/mcp/oauth/start", post(mcp::oauth_start)) .route("/mcp/oauth/start", post(mcp::oauth_start))
.route("/mcp/oauth/complete", post(mcp::oauth_complete)) .route("/mcp/oauth/complete", post(mcp::oauth_complete))
// user: interactive QR / device login for a pending per-user connector (§15)
.route("/mcp/login/status", post(mcp::login_status))
.route("/mcp/login/reset", post(mcp::login_reset))
// Dev / debug // Dev / debug
.route("/dev/debug_mode", get(dev::get_debug_mode).post(dev::set_debug_mode).put(dev::set_debug_mode)) .route("/dev/debug_mode", get(dev::get_debug_mode).post(dev::set_debug_mode).put(dev::set_debug_mode))
.route("/dev/llm-requests", get(dev::list_llm_requests)) .route("/dev/llm-requests", get(dev::list_llm_requests))
+86 -44
View File
@@ -16,6 +16,11 @@ import { t } from '../lib/i18n.js';
// that puts unvetted code on the box — which is why it needs `mcp.register_local_script` // that puts unvetted code on the box — which is why it needs `mcp.register_local_script`
// and why it sits second. // and why it sits second.
// //
// The manual path is a dedicated page (`#catalog/new`), not a dialog: the form is
// long and technical, a fixed modal grew taller than the viewport with no way to
// scroll, and a click on the overlay discarded everything typed so far. A page
// scrolls, and leaving it is a deliberate navigation.
//
// Reuses the shared `um-*` / bootstrap styling (no page-specific CSS). // Reuses the shared `um-*` / bootstrap styling (no page-specific CSS).
const ADMIN_ID = 'admin'; const ADMIN_ID = 'admin';
@@ -36,7 +41,8 @@ export class CatalogPage extends LightElement {
_rows: { state: true }, _rows: { state: true },
_addOpen: { state: true }, // the "Add connector" chooser _addOpen: { state: true }, // the "Add connector" chooser
_error: { state: true }, _error: { state: true },
_modal: { state: true }, _view: { state: true }, // 'list' | 'new'
_form: { state: true }, // manual-entry fields, when _view === 'new'
}; };
} }
@@ -51,7 +57,8 @@ export class CatalogPage extends LightElement {
this._rows = null; this._rows = null;
this._addOpen = false; this._addOpen = false;
this._error = null; this._error = null;
this._modal = null; this._view = 'list';
this._form = null;
} }
connectedCallback() { connectedCallback() {
@@ -61,7 +68,10 @@ export class CatalogPage extends LightElement {
window.addEventListener('llm-page-change', (e) => { window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === 'catalog'; this._open = e.detail.page === 'catalog';
this.style.display = this._open ? 'flex' : 'none'; this.style.display = this._open ? 'flex' : 'none';
if (this._open) this._load(); if (this._open) { this._syncViewFromHash(); this._load(); }
});
window.addEventListener('hashchange', () => {
if (this._open) this._syncViewFromHash();
}); });
document.addEventListener('click', () => { if (this._addOpen) this._addOpen = false; }); document.addEventListener('click', () => { if (this._addOpen) this._addOpen = false; });
} }
@@ -97,25 +107,43 @@ export class CatalogPage extends LightElement {
// ── Manual entry ─────────────────────────────────────────────────────────── // ── Manual entry ───────────────────────────────────────────────────────────
_openManual() { // The `new` view is derived from the `#catalog/new` sub-route, so the browser's
this._addOpen = false; // Back/Forward works and a pasted URL lands on the form. Entering the view
this._modal = { // always starts a fresh form.
form: { _syncViewFromHash() {
const parts = location.hash.slice(1).split('/');
const wantsNew = parts[0] === 'catalog' && parts[1] === 'new';
if (wantsNew && this._view !== 'new') {
this._error = null;
this._form = {
name: '', scope: 'per_user', source: 'remote', transport: 'stdio', name: '', scope: 'per_user', source: 'remote', transport: 'stdio',
command: '', args: '', url: '', script_path: '', config_schema: '', command: '', args: '', url: '', script_path: '', config_schema: '',
auth_kind: 'none', friendly_name: '', description: '', auth_kind: 'none', friendly_name: '', description: '',
}, };
}; }
this._view = wantsNew ? 'new' : 'list';
}
_openManual() {
this._addOpen = false;
history.pushState({ page: 'catalog', view: 'new' }, '', '#catalog/new');
this._syncViewFromHash();
}
_closeNew() {
// Prefer real history so the browser's own Back stays consistent; fall back to
// the list when this page was opened straight from a pasted URL.
if (history.length > 1) { history.back(); return; }
history.pushState({ page: 'catalog' }, '', '#catalog');
this._view = 'list';
} }
_patch(field, value) { _patch(field, value) {
this._modal = { ...this._modal, form: { ...this._modal.form, [field]: value } }; this._form = { ...this._form, [field]: value };
} }
_closeModal() { this._modal = null; this._error = null; }
async _saveManual() { async _saveManual() {
const f = this._modal.form; const f = this._form;
if (!f.name.trim()) { this._error = t('catalog.error.name'); return; } if (!f.name.trim()) { this._error = t('catalog.error.name'); return; }
const listField = (s) => s.split(/[\n,]/).map(x => x.trim()).filter(Boolean); const listField = (s) => s.split(/[\n,]/).map(x => x.trim()).filter(Boolean);
try { try {
@@ -137,7 +165,9 @@ export class CatalogPage extends LightElement {
description: f.description.trim() || null, description: f.description.trim() || null,
}), }),
}); });
this._closeModal(); this._view = 'list';
this._form = null;
history.pushState({ page: 'catalog' }, '', '#catalog');
await this._load(); await this._load();
} catch (e) { this._error = e.message; } } catch (e) { this._error = e.message; }
} }
@@ -154,6 +184,7 @@ export class CatalogPage extends LightElement {
render() { render() {
if (!this._open) return nothing; if (!this._open) return nothing;
if (this._view === 'new') return this._renderNew();
const rows = this._rows ?? []; const rows = this._rows ?? [];
const loading = this._rows === null && !this._error && this._isAdmin; const loading = this._rows === null && !this._error && this._isAdmin;
@@ -166,7 +197,7 @@ export class CatalogPage extends LightElement {
</div> </div>
</div> </div>
${this._error && !this._modal ? html` ${this._error ? html`
<div class="alert alert-danger py-2 mx-4" style="font-size:.85rem">${this._error}</div>` : nothing} <div class="alert alert-danger py-2 mx-4" style="font-size:.85rem">${this._error}</div>` : nothing}
<div style="padding:0 1.25rem 1.5rem; overflow:auto"> <div style="padding:0 1.25rem 1.5rem; overflow:auto">
@@ -183,8 +214,7 @@ export class CatalogPage extends LightElement {
${rows.length === 0 ? this._renderEmpty() : this._renderTable(rows)} ${rows.length === 0 ? this._renderEmpty() : this._renderTable(rows)}
`} `}
</div> </div>
</div> </div>`;
${this._renderModal()}`;
} }
// Bootstrap's own dropdown classes, not a hand-rolled panel: 5.3 themes // Bootstrap's own dropdown classes, not a hand-rolled panel: 5.3 themes
@@ -265,6 +295,14 @@ export class CatalogPage extends LightElement {
</div>`; </div>`;
} }
_area(label, value, oninput, opts = {}) {
return html`<div class="mb-3">
<label class="form-label">${label}${opts.hint ? html` <span class="text-muted">(${opts.hint})</span>` : nothing}</label>
<textarea class="form-control ${opts.mono ? 'font-monospace' : ''}" rows=${opts.rows || 3}
.value=${value} @input=${oninput}></textarea>
</div>`;
}
_select(label, value, options, onchange) { _select(label, value, options, onchange) {
return html`<div class="mb-3"> return html`<div class="mb-3">
<label class="form-label">${label}</label> <label class="form-label">${label}</label>
@@ -274,39 +312,43 @@ export class CatalogPage extends LightElement {
</div>`; </div>`;
} }
_renderModal() { _renderNew() {
if (!this._modal) return nothing; const f = this._form;
const f = this._modal.form;
const isScript = f.source === 'local_script'; const isScript = f.source === 'local_script';
return html` return html`
<div class="um-modal-overlay" @click=${(e) => { if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}> <div class="um-page">
<div class="um-modal"> <div class="um-header">
<div class="um-modal-header"> <div class="d-flex align-items-center gap-2" style="min-width:0">
<i class="bi bi-pencil"></i><span>${t('catalog.modal.title')}</span> <button class="btn btn-sm btn-outline-secondary" title=${t('catalog.new.back')} @click=${() => this._closeNew()}>
<button class="um-btn-icon ms-auto" @click=${() => this._closeModal()}><i class="bi bi-x-lg"></i></button> <i class="bi bi-arrow-left"></i>
</button>
<h2 class="um-title" style="min-width:0;overflow:hidden;text-overflow:ellipsis">
<i class="bi bi-pencil me-2"></i>${t('catalog.new.title')}</h2>
</div> </div>
<div class="um-modal-body"> </div>
<div style="padding:0 1.25rem 2rem; overflow:auto">
<div style="max-width:620px">
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:.85rem">${this._error}</div>` : nothing} ${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:.85rem">${this._error}</div>` : nothing}
${isScript ? html` ${isScript ? html`
<div class="alert alert-warning py-2 mb-3" style="font-size:.78rem">${unsafeHTML(t('catalog.modal.script_warn'))}</div>` : nothing} <div class="alert alert-warning py-2 mb-3" style="font-size:.78rem">${unsafeHTML(t('catalog.new.script_warn'))}</div>` : nothing}
${this._field(t('catalog.modal.name'), f.name, e => this._patch('name', e.target.value), { hint: t('catalog.modal.name_hint'), mono: true })} ${this._field(t('catalog.new.name'), f.name, e => this._patch('name', e.target.value), { hint: t('catalog.new.name_hint'), mono: true })}
${this._select(t('catalog.modal.scope'), f.scope, ['per_user', 'global'], e => this._patch('scope', e.target.value))} ${this._select(t('catalog.new.scope'), f.scope, ['per_user', 'global'], e => this._patch('scope', e.target.value))}
${this._select(t('catalog.modal.type'), f.source, ['remote', 'local_script'], e => this._patch('source', e.target.value))} ${this._select(t('catalog.new.type'), f.source, ['remote', 'local_script'], e => this._patch('source', e.target.value))}
${this._select(t('catalog.modal.transport'), f.transport, ['stdio', 'http', 'sse'], e => this._patch('transport', e.target.value))} ${this._select(t('catalog.new.transport'), f.transport, ['stdio', 'http', 'sse'], e => this._patch('transport', e.target.value))}
${isScript ${isScript
? html`${this._field(t('catalog.modal.command'), f.command, e => this._patch('command', e.target.value), { placeholder: t('catalog.modal.command_ph'), mono: true })} ? html`${this._field(t('catalog.new.command'), f.command, e => this._patch('command', e.target.value), { placeholder: t('catalog.new.command_ph'), mono: true })}
${this._field(t('catalog.modal.script_path'), f.script_path, e => this._patch('script_path', e.target.value), { hint: t('catalog.modal.script_path_hint'), mono: true })}` ${this._field(t('catalog.new.script_path'), f.script_path, e => this._patch('script_path', e.target.value), { hint: t('catalog.new.script_path_hint'), mono: true })}`
: this._field(t('catalog.modal.url'), f.url, e => this._patch('url', e.target.value), { mono: true })} : this._field(t('catalog.new.url'), f.url, e => this._patch('url', e.target.value), { mono: true })}
${this._field(t('catalog.modal.args'), f.args, e => this._patch('args', e.target.value), { hint: t('catalog.modal.args_hint'), mono: true })} ${this._area(t('catalog.new.args'), f.args, e => this._patch('args', e.target.value), { hint: t('catalog.new.args_hint'), mono: true })}
${this._field(t('catalog.modal.config_schema'), f.config_schema, e => this._patch('config_schema', e.target.value), { hint: t('catalog.modal.config_schema_hint'), mono: true })} ${this._area(t('catalog.new.config_schema'), f.config_schema, e => this._patch('config_schema', e.target.value), { hint: t('catalog.new.config_schema_hint'), mono: true })}
${this._select(t('catalog.modal.auth'), f.auth_kind, ['none', 'api_key', 'oauth', 'qr', 'ssh_key'], e => this._patch('auth_kind', e.target.value))} ${this._select(t('catalog.new.auth'), f.auth_kind, ['none', 'api_key', 'oauth', 'qr', 'ssh_key'], e => this._patch('auth_kind', e.target.value))}
${this._field(t('catalog.modal.friendly'), f.friendly_name, e => this._patch('friendly_name', e.target.value))} ${this._field(t('catalog.new.friendly'), f.friendly_name, e => this._patch('friendly_name', e.target.value))}
${this._field(t('catalog.modal.desc'), f.description, e => this._patch('description', e.target.value), { hint: t('catalog.modal.desc_hint') })} ${this._area(t('catalog.new.desc'), f.description, e => this._patch('description', e.target.value), { hint: t('catalog.new.desc_hint'), rows: 2 })}
</div> <div class="d-flex justify-content-end gap-2 mt-3">
<div class="um-modal-footer"> <button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeNew()}>${t('catalog.new.cancel')}</button>
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>${t('catalog.modal.cancel')}</button> <button class="btn btn-sm btn-primary" @click=${() => this._saveManual()}>
<button class="btn btn-sm btn-primary" @click=${() => this._saveManual()}> <i class="bi bi-check-lg me-1"></i>${t('catalog.new.save')}</button>
<i class="bi bi-check-lg me-1"></i>${t('catalog.modal.save')}</button> </div>
</div> </div>
</div> </div>
</div>`; </div>`;
+51 -8
View File
@@ -2,6 +2,23 @@ import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js'; import { t } from '../lib/i18n.js';
function _maybeT(key, fallback) {
const v = t(key);
return v !== key ? v : fallback;
}
function _configSetSlug(name) {
const slugs = {
'Interface': 'interface',
'TIC Agent': 'tic_agent',
};
return slugs[name] ?? null;
}
function _propKeyId(propKey) {
return propKey.replace(/\./g, '__');
}
export class ConfigPage extends LightElement { export class ConfigPage extends LightElement {
static properties = { static properties = {
_open: { state: true }, _open: { state: true },
@@ -130,7 +147,7 @@ export class ConfigPage extends LightElement {
.checked=${checked} .checked=${checked}
@change=${e => { this._setValue(prop.key, e.target.checked ? 'true' : 'false'); this._save(prop); }} /> @change=${e => { this._setValue(prop.key, e.target.checked ? 'true' : 'false'); this._save(prop); }} />
<label class="form-check-label" for="cfg-${prop.key}"> <label class="form-check-label" for="cfg-${prop.key}">
${checked ? 'Enabled' : 'Disabled'} ${checked ? t('config.enabled') : t('config.disabled')}
</label> </label>
</div>`; </div>`;
} }
@@ -144,7 +161,13 @@ export class ConfigPage extends LightElement {
@input=${e => this._setValue(prop.key, e.target.value)} />`; @input=${e => this._setValue(prop.key, e.target.value)} />`;
} }
// Dropdown-style property types. The backend ships the allowed values in
// `prop.options` (a list of {id, name}); we only decide how to frame them.
// Adding a new custom type from a config section? Give it a `property_type`
// on the backend, attach its `options`, and add a branch like these — a
// free-text box becomes a proper picker for the price of a few lines.
if (prop.property_type === 'security_group') { if (prop.property_type === 'security_group') {
// Nullable: the empty choice means "fall back to the instance default".
const groups = prop.options ?? []; const groups = prop.options ?? [];
return html` return html`
<select class="form-select form-select-sm config-input" <select class="form-select form-select-sm config-input"
@@ -156,6 +179,20 @@ export class ConfigPage extends LightElement {
</select>`; </select>`;
} }
if (prop.property_type === 'locale') {
// Interface languages the instance supports; labels are native endonyms.
// Always a concrete pick (no empty option) — falls back to default_value.
const locales = prop.options ?? [];
const current = val || prop.default_value || 'en';
return html`
<select class="form-select form-select-sm config-input"
.value=${current}
@change=${e => { this._setValue(prop.key, e.target.value); this._save(prop); }}>
${locales.map(l => html`
<option value=${l.id} ?selected=${current === l.id}>${l.name}</option>`)}
</select>`;
}
return html` return html`
<input type="text" <input type="text"
class="form-control form-control-sm config-input" class="form-control form-control-sm config-input"
@@ -165,11 +202,14 @@ export class ConfigPage extends LightElement {
} }
_renderSet(set) { _renderSet(set) {
const slug = _configSetSlug(set.name);
const sName = slug ? _maybeT(`config.set.${slug}.name`, set.name) : set.name;
const sDesc = slug ? _maybeT(`config.set.${slug}.desc`, set.description) : set.description;
return html` return html`
<div class="config-set"> <div class="config-set">
<div class="config-set-header"> <div class="config-set-header">
<div class="config-set-name">${set.name}</div> <div class="config-set-name">${sName}</div>
<div class="config-set-desc">${set.description}</div> <div class="config-set-desc">${sDesc}</div>
</div> </div>
<div class="config-rows"> <div class="config-rows">
${set.properties.map(p => this._renderRow(p))} ${set.properties.map(p => this._renderRow(p))}
@@ -180,22 +220,25 @@ export class ConfigPage extends LightElement {
_renderRow(prop) { _renderRow(prop) {
const saving = this._saving.has(prop.key); const saving = this._saving.has(prop.key);
const saved = this._saved.has(prop.key); const saved = this._saved.has(prop.key);
const pk = _propKeyId(prop.key);
const pName = _maybeT(`config.prop.${pk}.name`, prop.name);
const pDesc = _maybeT(`config.prop.${pk}.desc`, prop.description);
return html` return html`
<div class="config-row"> <div class="config-row">
<div class="config-row-meta"> <div class="config-row-meta">
<div class="config-row-name">${prop.name}</div> <div class="config-row-name">${pName}</div>
<div class="config-row-desc">${prop.description}</div> <div class="config-row-desc">${pDesc}</div>
</div> </div>
<div class="config-row-control"> <div class="config-row-control">
${this._renderInput(prop)} ${this._renderInput(prop)}
${prop.property_type !== 'bool' ? html` ${!['bool', 'locale'].includes(prop.property_type) ? html`
<button class="btn btn-sm ${saved ? 'btn-success' : 'btn-primary'} config-save-btn" <button class="btn btn-sm ${saved ? 'btn-success' : 'btn-primary'} config-save-btn"
?disabled=${saving} ?disabled=${saving}
@click=${() => this._save(prop)}> @click=${() => this._save(prop)}>
${saving ${saving
? html`<span class="spinner-border spinner-border-sm"></span>` ? html`<span class="spinner-border spinner-border-sm"></span>`
: saved ? 'Saved' : 'Save'} : saved ? t('common.saved') : t('common.save')}
</button>` : nothing} </button>` : nothing}
</div> </div>
</div>`; </div>`;
@@ -237,7 +280,7 @@ export class ConfigPage extends LightElement {
?disabled=${this._debugLoading} ?disabled=${this._debugLoading}
@change=${() => this._toggleDebugMode()} /> @change=${() => this._toggleDebugMode()} />
<label class="form-check-label" for="cfg-debug-mode"> <label class="form-check-label" for="cfg-debug-mode">
${this._debugMode ? 'Enabled' : 'Disabled'} ${this._debugMode ? t('config.enabled') : t('config.disabled')}
</label> </label>
</div> </div>
</div> </div>
+137 -1
View File
@@ -48,6 +48,7 @@ export class ConnectorDetailPage extends LightElement {
_access: { state: true }, // admin: Set of granted user ids _access: { state: true }, // admin: Set of granted user ids
_noIcon: { state: true }, _noIcon: { state: true },
_oauth: { state: true }, // in-flight OAuth login: { state, auth_url, code } _oauth: { state: true }, // in-flight OAuth login: { state, auth_url, code }
_qr: { state: true }, // in-flight QR/device login: { state, qr, message }
}; };
} }
@@ -72,6 +73,9 @@ export class ConnectorDetailPage extends LightElement {
this._users = null; this._users = null;
this._access = null; this._access = null;
this._oauth = null; this._oauth = null;
this._qr = null;
this._qrServerId = null;
this._stopQrPoll();
} }
connectedCallback() { connectedCallback() {
@@ -82,6 +86,7 @@ export class ConnectorDetailPage extends LightElement {
this._open = e.detail.page === PAGE_ID; this._open = e.detail.page === PAGE_ID;
this.style.display = this._open ? 'flex' : 'none'; this.style.display = this._open ? 'flex' : 'none';
if (this._open) this._loadFromHash(); if (this._open) this._loadFromHash();
else this._stopQrPoll(); // never poll a connector's login off-screen
}); });
window.addEventListener('hashchange', () => { window.addEventListener('hashchange', () => {
if (this._open) this._loadFromHash(); if (this._open) this._loadFromHash();
@@ -90,12 +95,18 @@ export class ConnectorDetailPage extends LightElement {
disconnectedCallback() { disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged); window.removeEventListener('locale-changed', this.__onLocaleChanged);
this._stopQrPoll();
super.disconnectedCallback(); super.disconnectedCallback();
} }
get _isAdmin() { return this._me?.role_id === ADMIN_ID; } get _isAdmin() { return this._me?.role_id === ADMIN_ID; }
get _isGlobal() { return (this._entry?.scope ?? (this._glob ? 'global' : null)) === 'global'; } get _isGlobal() { return (this._entry?.scope ?? (this._glob ? 'global' : null)) === 'global'; }
get _status() { return statusOf({ _act: this._act, _glob: this._glob }); } get _status() {
const s = statusOf({ _act: this._act, _glob: this._glob });
// A QR/device connector at `pending` is waiting for its scan, not misconfigured.
if (s === 'pending' && this._entry?.auth_kind === 'qr') return 'needs_login';
return s;
}
async _loadFromHash() { async _loadFromHash() {
const name = nameFromHash(); const name = nameFromHash();
@@ -267,6 +278,76 @@ export class ConnectorDetailPage extends LightElement {
finally { this._busy = false; } finally { this._busy = false; }
} }
// ── QR / device login (§15): activate → server emits a QR → scan → poll ready ──
// Unlike OAuth there is no code to paste: the connector's server must run to
// produce the QR, so activation starts it and we poll `login_status` until the
// phone scan flips it to `ready`.
async _startQrLogin() {
this._busy = true; this._error = null;
try {
// First sign-in creates the pending row (which installs deps + starts the
// server — this can take a while on a cold container). Reuse it thereafter.
let serverId = this._act?.id;
if (!serverId) {
const res = await jf('/api/mcp/activate', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ catalog_name: this._name }),
});
if (res?.error) { this._error = res.error; return; }
serverId = res.id;
}
this._qrServerId = serverId;
await this._pollQr(); // fetch the first QR immediately
this._startQrPoll(); // then keep it fresh
await this._load();
} catch (e) { this._error = e.message; }
finally { this._busy = false; }
}
_startQrPoll() {
this._stopQrPoll();
// The QR rotates every ~20 s and the scan can land any moment: poll briskly.
this.__qrTimer = setInterval(() => this._pollQr(), 2500);
}
_stopQrPoll() {
if (this.__qrTimer) { clearInterval(this.__qrTimer); this.__qrTimer = null; }
}
async _pollQr() {
if (!this._qrServerId) return;
try {
const res = await jf('/api/mcp/login/status', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ server_id: this._qrServerId }),
});
this._qr = res;
if (res?.state === 'ready') {
this._stopQrPoll();
await this._load(); // pick up the flipped auth_state
}
} catch (_) { /* transient (server still connecting) — keep polling */ }
}
async _resetQrLogin() {
const id = this._qrServerId || this._act?.id;
if (!id) return;
this._busy = true; this._error = null;
try {
await jf('/api/mcp/login/reset', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ server_id: id }),
});
this._qr = null;
this._qrServerId = id;
await this._pollQr();
this._startQrPoll();
await this._load();
} catch (e) { this._error = e.message; }
finally { this._busy = false; }
}
async _enableGlobal() { async _enableGlobal() {
this._busy = true; this._error = null; this._busy = true; this._error = null;
try { try {
@@ -451,6 +532,18 @@ export class ConnectorDetailPage extends LightElement {
</div>`; </div>`;
} }
// QR / device login (WhatsApp): the server produces a QR the user scans with
// their phone — its own panel, like OAuth.
if (e.auth_kind === 'qr' && !this._isGlobal) {
return html`
<div style="margin-top:1.5rem">
<div class="um-header" style="padding:0 0 .5rem">
<h3 class="um-title" style="font-size:1rem"><i class="bi bi-qr-code me-2"></i>${t('connectors.detail.qr.title')}</h3>
</div>
${this._renderQr()}
</div>`;
}
return html` return html`
<div style="margin-top:1.5rem"> <div style="margin-top:1.5rem">
<div class="um-header" style="padding:0 0 .5rem"> <div class="um-header" style="padding:0 0 .5rem">
@@ -561,6 +654,49 @@ export class ConnectorDetailPage extends LightElement {
`; `;
} }
_renderQr() {
const active = this._act && this._act.auth_state === 'ready';
const q = this._qr;
const st = q?.state;
const polling = !!this.__qrTimer;
return html`
<div class="text-muted mb-3" style="font-size:.78rem">${t('connectors.detail.qr.desc')}</div>
${active && st !== 'need_scan' && st !== 'logged_out' ? html`
<div class="alert alert-success py-2 mb-3" style="font-size:.82rem">
<i class="bi bi-check-circle-fill me-1"></i>${t('connectors.detail.qr.connected')}
</div>` : nothing}
${st === 'need_scan' && q?.qr ? html`
<div class="connector-card" style="text-align:center; margin-bottom:.75rem">
<div class="mb-2" style="font-size:.82rem">${t('connectors.detail.qr.scan')}</div>
<img src=${q.qr} alt="WhatsApp QR"
style="width:280px; max-width:100%; height:auto; border-radius:8px; background:#fff; padding:10px" />
<div class="text-muted mt-2" style="font-size:.72rem">${t('connectors.detail.qr.hint')}</div>
</div>` : nothing}
${polling && st && st !== 'ready' && st !== 'need_scan' ? html`
<div class="d-flex align-items-center gap-2 mb-2 text-muted" style="font-size:.8rem">
<i class="bi bi-arrow-repeat"></i>${q?.message || t('connectors.detail.qr.connecting')}
</div>` : nothing}
<div class="d-flex gap-2 flex-wrap" style="margin-top:.5rem">
${!active && !polling ? html`
<button class="btn btn-sm btn-primary" ?disabled=${this._busy} @click=${() => this._startQrLogin()}>
<i class="bi bi-qr-code me-1"></i>${this._busy ? t('connectors.detail.qr.btn_starting') : t('connectors.detail.qr.btn_start')}
</button>` : nothing}
${active || polling ? html`
<button class="btn btn-sm btn-outline-secondary" ?disabled=${this._busy} @click=${() => this._resetQrLogin()}>
<i class="bi bi-arrow-repeat me-1"></i>${t('connectors.detail.qr.btn_relink')}
</button>` : nothing}
${this._act ? html`
<button class="btn btn-sm btn-outline-danger" ?disabled=${this._busy} @click=${() => this._deactivate()}>
<i class="bi bi-trash me-1"></i>${t('connectors.detail.oauth.deactivate')}
</button>` : nothing}
</div>`;
}
_renderEnvFields() { _renderEnvFields() {
if (!this._schema.length) return nothing; if (!this._schema.length) return nothing;
return this._schema.map(f => html` return this._schema.map(f => html`
+9 -3
View File
@@ -245,9 +245,14 @@ export class MarketplacePage extends LightElement {
: html`<div class="connector-card-icon connector-card-icon--empty"><i class="bi bi-plug"></i></div>`} : html`<div class="connector-card-icon connector-card-icon--empty"><i class="bi bi-plug"></i></div>`}
<div class="connector-card-title"> <div class="connector-card-title">
<div class="connector-card-name">${c.name}</div> <div class="connector-card-name">${c.name}</div>
<div class="connector-card-sub">${c.id}${c.version ? ` · v${c.version}` : ''}</div> <div class="connector-card-sub">
${c.id}${c.version_string ? ` · ${c.version_string}` : (c.version != null ? ` · v${c.version}` : '')}
${c.update_available && c.installed_version != null ? html`<span style="opacity:.7"> · ${t('marketplace.card.installed_version', { v: c.installed_version })}</span>` : nothing}
</div>
</div> </div>
${c.installed ? html`<span class="connector-chip connector-chip--ok">${t('marketplace.card.installed')}</span>` : nothing} ${c.update_available
? html`<span class="connector-chip connector-chip--script"><i class="bi bi-arrow-up-circle me-1"></i>${t('marketplace.card.update_available')}</span>`
: c.installed ? html`<span class="connector-chip connector-chip--ok">${t('marketplace.card.installed')}</span>` : nothing}
</div> </div>
${c.user_description ? html`<div class="connector-card-desc">${c.user_description}</div>` : nothing} ${c.user_description ? html`<div class="connector-card-desc">${c.user_description}</div>` : nothing}
@@ -277,9 +282,10 @@ export class MarketplacePage extends LightElement {
</details>` : nothing} </details>` : nothing}
<div class="connector-card-actions"> <div class="connector-card-actions">
<button class="btn btn-sm ${c.installed ? 'btn-outline-primary' : 'btn-primary'}" <button class="btn btn-sm ${(c.installed && !c.update_available) ? 'btn-outline-primary' : 'btn-primary'}"
?disabled=${busy} @click=${() => this._install(c)}> ?disabled=${busy} @click=${() => this._install(c)}>
${busy ? html`<i class="bi bi-hourglass-split me-1"></i>${t('marketplace.card.installing')}` ${busy ? html`<i class="bi bi-hourglass-split me-1"></i>${t('marketplace.card.installing')}`
: c.update_available ? html`<i class="bi bi-arrow-up-circle me-1"></i>${t('marketplace.card.update')}`
: c.installed ? html`<i class="bi bi-arrow-repeat me-1"></i>${t('marketplace.card.reinstall')}` : c.installed ? html`<i class="bi bi-arrow-repeat me-1"></i>${t('marketplace.card.reinstall')}`
: html`<i class="bi bi-download me-1"></i>${t('marketplace.card.install')}`} : html`<i class="bi bi-download me-1"></i>${t('marketplace.card.install')}`}
</button> </button>
+53 -22
View File
@@ -153,6 +153,22 @@ export default {
'config.loading': 'Loading…', 'config.loading': 'Loading…',
'config.developer': 'Developer', 'config.developer': 'Developer',
'config.error_save':'Error saving "{name}": {msg}', 'config.error_save':'Error saving "{name}": {msg}',
'config.enabled': 'Enabled',
'config.disabled': 'Disabled',
'config.set.interface.name': 'Interface',
'config.set.interface.desc': 'Look and feel of the web interface.',
'config.set.tic_agent.name': 'TIC Agent',
'config.set.tic_agent.desc': 'TIC is a background agent that monitors all async events generated by connected MCP servers (new emails, calendar updates, WhatsApp messages, etc.). It reads your notification rules from data/notifications.md and your memory to decide — via an LLM call — which events are worth surfacing. Relevant notifications are forwarded to the home agent set via /sethome.',
'config.prop.ui_locale.name': 'Language',
'config.prop.ui_locale.desc': 'Default interface language for the whole instance. Each user can override it on their profile.',
'config.prop.tic__enabled.name': 'Enabled',
'config.prop.tic__enabled.desc': 'Enable or disable the TIC agent. When disabled, no MCP events are processed.',
'config.prop.tic__security_group.name': 'Security Group',
'config.prop.tic__security_group.desc': 'Tool permission group applied to each TIC agent session. Leave empty to use the default group.',
'config.prop.tic__interval_minutes.name': 'Check Interval (minutes)',
'config.prop.tic__interval_minutes.desc': 'How often TIC runs, in minutes. Leave empty to use the value from config.yml (tic.interval_secs).',
// ── Projects ──────────────────────────────────────────────────────────────── // ── Projects ────────────────────────────────────────────────────────────────
'projects.title': 'Projects', 'projects.title': 'Projects',
@@ -799,6 +815,16 @@ export default {
'connectors.detail.oauth.cancel': 'Cancel', 'connectors.detail.oauth.cancel': 'Cancel',
'connectors.detail.oauth.deactivate': 'Deactivate', 'connectors.detail.oauth.deactivate': 'Deactivate',
'connectors.detail.qr.title': 'Link your phone',
'connectors.detail.qr.desc': 'Scan a QR code with your phone to link this device. The session stays on this box — no password is stored.',
'connectors.detail.qr.connected': 'Connected and active.',
'connectors.detail.qr.scan': 'Scan this code with your phone:',
'connectors.detail.qr.hint': 'WhatsApp → Settings → Linked Devices → Link a Device.',
'connectors.detail.qr.connecting': 'Connecting…',
'connectors.detail.qr.btn_start': 'Start sign-in',
'connectors.detail.qr.btn_starting': 'Preparing… (this can take a minute the first time)',
'connectors.detail.qr.btn_relink': 'Re-link (new QR)',
'connectors.detail.test.running': 'Testing credentials…', 'connectors.detail.test.running': 'Testing credentials…',
'connectors.detail.test.skipped': 'No verification step for this connector.', 'connectors.detail.test.skipped': 'No verification step for this connector.',
'connectors.detail.test.ok_label': 'OK', 'connectors.detail.test.ok_label': 'OK',
@@ -891,6 +917,9 @@ export default {
'marketplace.grid.no_match': 'No connector matches these filters.', 'marketplace.grid.no_match': 'No connector matches these filters.',
'marketplace.card.installed': 'installed', 'marketplace.card.installed': 'installed',
'marketplace.card.update_available': 'update available',
'marketplace.card.installed_version': 'installed v{v}',
'marketplace.card.update': 'Update',
'marketplace.card.scope_global': 'global', 'marketplace.card.scope_global': 'global',
'marketplace.card.scope_per_user': 'per-user', 'marketplace.card.scope_per_user': 'per-user',
'marketplace.card.type_script': 'local script', 'marketplace.card.type_script': 'local script',
@@ -986,28 +1015,29 @@ export default {
'catalog.action.remove': 'Remove from catalog', 'catalog.action.remove': 'Remove from catalog',
'catalog.modal.title': 'Add connector manually', 'catalog.new.back': 'Back',
'catalog.modal.script_warn': '<i class="bi bi-exclamation-triangle me-1"></i>A local script runs code on this box. Nothing verifies it — unlike the marketplace path, there is no digest to check.', 'catalog.new.title': 'Add connector manually',
'catalog.modal.name': 'Name', 'catalog.new.script_warn': '<i class="bi bi-exclamation-triangle me-1"></i>A local script runs code on this box. Nothing verifies it — unlike the marketplace path, there is no digest to check.',
'catalog.modal.name_hint': 'slug', 'catalog.new.name': 'Name',
'catalog.modal.scope': 'Scope', 'catalog.new.name_hint': 'slug',
'catalog.modal.type': 'Type', 'catalog.new.scope': 'Scope',
'catalog.modal.transport': 'Transport', 'catalog.new.type': 'Type',
'catalog.modal.command': 'Command', 'catalog.new.transport': 'Transport',
'catalog.modal.command_ph': 'python3', 'catalog.new.command': 'Command',
'catalog.modal.script_path': 'Script path', 'catalog.new.command_ph': 'python3',
'catalog.modal.script_path_hint': 'as <connector>/<file>, under ./connectors', 'catalog.new.script_path': 'Script path',
'catalog.modal.url': 'URL', 'catalog.new.script_path_hint': 'as <connector>/<file>, under ./connectors',
'catalog.modal.args': 'Args', 'catalog.new.url': 'URL',
'catalog.modal.args_hint': 'one per line', 'catalog.new.args': 'Args',
'catalog.modal.config_schema': 'Required secret/env keys', 'catalog.new.args_hint': 'one per line',
'catalog.modal.config_schema_hint': 'comma/newline', 'catalog.new.config_schema': 'Required secret/env keys',
'catalog.modal.auth': 'Auth', 'catalog.new.config_schema_hint': 'comma/newline',
'catalog.modal.friendly': 'Friendly name', 'catalog.new.auth': 'Auth',
'catalog.modal.desc': 'Description', 'catalog.new.friendly': 'Friendly name',
'catalog.modal.desc_hint': 'the LLM reads this when deciding to activate the connector', 'catalog.new.desc': 'Description',
'catalog.modal.cancel': 'Cancel', 'catalog.new.desc_hint': 'the LLM reads this when deciding to activate the connector',
'catalog.modal.save': 'Add to catalog', 'catalog.new.cancel': 'Cancel',
'catalog.new.save': 'Add to catalog',
'catalog.error.name': 'Name is required.', 'catalog.error.name': 'Name is required.',
'catalog.confirm.delete': 'Remove "{name}" from the catalog?\n\nAnything already activated from it keeps running.', 'catalog.confirm.delete': 'Remove "{name}" from the catalog?\n\nAnything already activated from it keeps running.',
@@ -1039,6 +1069,7 @@ export default {
'common.saving': 'Saving…', 'common.saving': 'Saving…',
'common.cancel': 'Cancel', 'common.cancel': 'Cancel',
'common.loading': 'Loading…', 'common.loading': 'Loading…',
'common.saved': 'Saved',
// ── Shared Folders (blueprint §6) ──────────────────────────────────────────── // ── Shared Folders (blueprint §6) ────────────────────────────────────────────
'nav.shared_folders': 'Shared Folders', 'nav.shared_folders': 'Shared Folders',
+40 -22
View File
@@ -153,6 +153,22 @@ export default {
'config.loading': 'Chargement…', 'config.loading': 'Chargement…',
'config.developer': 'Développeur', 'config.developer': 'Développeur',
'config.error_save':'Erreur lors de l\'enregistrement de "{name}" : {msg}', 'config.error_save':'Erreur lors de l\'enregistrement de "{name}" : {msg}',
'config.enabled': 'Activé',
'config.disabled': 'Désactivé',
'config.set.interface.name': 'Interface',
'config.set.interface.desc': 'Aspect et style de l\'interface web.',
'config.set.tic_agent.name': 'Agent TIC',
'config.set.tic_agent.desc': 'TIC est un agent d\'arrière-plan qui surveille tous les événements asynchrones générés par les serveurs MCP connectés (nouveaux e-mails, mises à jour du calendrier, messages WhatsApp, etc.). Il lit vos règles de notification dans data/notifications.md et votre mémoire pour décider — via un appel LLM — quels événements méritent d\'être signalés. Les notifications pertinentes sont transmises à l\'agent d\'accueil défini via /sethome.',
'config.prop.ui_locale.name': 'Langue',
'config.prop.ui_locale.desc': 'Langue d\'interface par défaut pour l\'ensemble de l\'instance. Chaque utilisateur peut la modifier dans son profil.',
'config.prop.tic__enabled.name': 'Activé',
'config.prop.tic__enabled.desc': 'Activer ou désactiver l\'agent TIC. Lorsqu\'il est désactivé, aucun événement MCP n\'est traité.',
'config.prop.tic__security_group.name': 'Groupe de sécurité',
'config.prop.tic__security_group.desc': 'Groupe de permissions d\'outils appliqué à chaque session de l\'agent TIC. Laissez vide pour utiliser le groupe par défaut.',
'config.prop.tic__interval_minutes.name': 'Intervalle de vérification (minutes)',
'config.prop.tic__interval_minutes.desc': 'Fréquence d\'exécution de TIC, en minutes. Laissez vide pour utiliser la valeur de config.yml (tic.interval_secs).',
// ── Projects ──────────────────────────────────────────────────────────────── // ── Projects ────────────────────────────────────────────────────────────────
'projects.title': 'Projets', 'projects.title': 'Projets',
@@ -986,28 +1002,29 @@ export default {
'catalog.action.remove': 'Retirer du catalogue', 'catalog.action.remove': 'Retirer du catalogue',
'catalog.modal.title': 'Ajouter un connecteur manuellement', 'catalog.new.back': 'Retour',
'catalog.modal.script_warn': '<i class="bi bi-exclamation-triangle me-1"></i>Un script local exécute du code sur cette machine. Rien ne le vérifie — contrairement au Marketplace, il n\'y a pas de condensé à contrôler.', 'catalog.new.title': 'Ajouter un connecteur manuellement',
'catalog.modal.name': 'Nom', 'catalog.new.script_warn': '<i class="bi bi-exclamation-triangle me-1"></i>Un script local exécute du code sur cette machine. Rien ne le vérifie — contrairement au Marketplace, il n\'y a pas de condensé à contrôler.',
'catalog.modal.name_hint': 'slug', 'catalog.new.name': 'Nom',
'catalog.modal.scope': 'Portée', 'catalog.new.name_hint': 'slug',
'catalog.modal.type': 'Type', 'catalog.new.scope': 'Portée',
'catalog.modal.transport': 'Transport', 'catalog.new.type': 'Type',
'catalog.modal.command': 'Commande', 'catalog.new.transport': 'Transport',
'catalog.modal.command_ph': 'python3', 'catalog.new.command': 'Commande',
'catalog.modal.script_path': 'Chemin du script', 'catalog.new.command_ph': 'python3',
'catalog.modal.script_path_hint': 'comme <connecteur>/<fichier>, sous ./connectors', 'catalog.new.script_path': 'Chemin du script',
'catalog.modal.url': 'URL', 'catalog.new.script_path_hint': 'comme <connecteur>/<fichier>, sous ./connectors',
'catalog.modal.args': 'Arguments', 'catalog.new.url': 'URL',
'catalog.modal.args_hint': 'un par ligne', 'catalog.new.args': 'Arguments',
'catalog.modal.config_schema': 'Clés secrètes/env requises', 'catalog.new.args_hint': 'un par ligne',
'catalog.modal.config_schema_hint': 'virgule/nouvelle ligne', 'catalog.new.config_schema': 'Clés secrètes/env requises',
'catalog.modal.auth': 'Auth', 'catalog.new.config_schema_hint': 'virgule/nouvelle ligne',
'catalog.modal.friendly': 'Nom convivial', 'catalog.new.auth': 'Auth',
'catalog.modal.desc': 'Description', 'catalog.new.friendly': 'Nom convivial',
'catalog.modal.desc_hint': 'le LLM lit ceci pour décider d\'activer le connecteur', 'catalog.new.desc': 'Description',
'catalog.modal.cancel': 'Annuler', 'catalog.new.desc_hint': 'le LLM lit ceci pour décider d\'activer le connecteur',
'catalog.modal.save': 'Ajouter au catalogue', 'catalog.new.cancel': 'Annuler',
'catalog.new.save': 'Ajouter au catalogue',
'catalog.error.name': 'Le nom est requis.', 'catalog.error.name': 'Le nom est requis.',
'catalog.confirm.delete': 'Retirer "{name}" du catalogue ?\n\nTout ce qui a déjà été activé continuera de fonctionner.', 'catalog.confirm.delete': 'Retirer "{name}" du catalogue ?\n\nTout ce qui a déjà été activé continuera de fonctionner.',
@@ -1039,6 +1056,7 @@ export default {
'common.saving': 'Enregistrement…', 'common.saving': 'Enregistrement…',
'common.cancel': 'Annuler', 'common.cancel': 'Annuler',
'common.loading': 'Chargement…', 'common.loading': 'Chargement…',
'common.saved': 'Enregistré',
// ── Shared Folders (blueprint §6) ──────────────────────────────────────────── // ── Shared Folders (blueprint §6) ────────────────────────────────────────────
'nav.shared_folders': 'Dossiers partagés', 'nav.shared_folders': 'Dossiers partagés',
+40 -22
View File
@@ -177,6 +177,22 @@ export default {
'config.loading': 'Caricamento…', 'config.loading': 'Caricamento…',
'config.developer': 'Sviluppatore', 'config.developer': 'Sviluppatore',
'config.error_save':'Errore durante il salvataggio di "{name}": {msg}', 'config.error_save':'Errore durante il salvataggio di "{name}": {msg}',
'config.enabled': 'Attivato',
'config.disabled': 'Disattivato',
'config.set.interface.name': 'Interfaccia',
'config.set.interface.desc': 'Aspetto e stile dell\'interfaccia web.',
'config.set.tic_agent.name': 'Agente TIC',
'config.set.tic_agent.desc': 'TIC è un agente in background che monitora tutti gli eventi asincroni generati dai server MCP connessi (nuove email, aggiornamenti del calendario, messaggi WhatsApp, ecc.). Legge le regole di notifica da data/notifications.md e la memoria per decidere — tramite una chiamata LLM — quali eventi vale la pena segnalare. Le notifiche rilevanti vengono inoltrate all\'agente predefinito impostato tramite /sethome.',
'config.prop.ui_locale.name': 'Lingua',
'config.prop.ui_locale.desc': 'Lingua predefinita per l\'intera istanza. Ogni utente può modificarla nel proprio profilo.',
'config.prop.tic__enabled.name': 'Attivo',
'config.prop.tic__enabled.desc': 'Attiva o disattiva l\'agente TIC. Quando disattivato, nessun evento MCP viene elaborato.',
'config.prop.tic__security_group.name': 'Gruppo di sicurezza',
'config.prop.tic__security_group.desc': 'Gruppo di permessi strumenti applicato a ogni sessione dell\'agente TIC. Lascia vuoto per usare il gruppo predefinito.',
'config.prop.tic__interval_minutes.name': 'Intervallo di controllo (minuti)',
'config.prop.tic__interval_minutes.desc': 'Ogni quanto TIC viene eseguito, in minuti. Lascia vuoto per usare il valore da config.yml (tic.interval_secs).',
// ── Projects ──────────────────────────────────────────────────────────────── // ── Projects ────────────────────────────────────────────────────────────────
'projects.title': 'Progetti', 'projects.title': 'Progetti',
@@ -986,28 +1002,29 @@ export default {
'catalog.action.remove': 'Rimuovi dal catalogo', 'catalog.action.remove': 'Rimuovi dal catalogo',
'catalog.modal.title': 'Aggiungi connettore manualmente', 'catalog.new.back': 'Indietro',
'catalog.modal.script_warn': '<i class="bi bi-exclamation-triangle me-1"></i>Uno script locale esegue codice su questo computer. Niente lo verifica — a differenza del marketplace, non c\'è un digest da controllare.', 'catalog.new.title': 'Aggiungi connettore manualmente',
'catalog.modal.name': 'Nome', 'catalog.new.script_warn': '<i class="bi bi-exclamation-triangle me-1"></i>Uno script locale esegue codice su questo computer. Niente lo verifica — a differenza del marketplace, non c\'è un digest da controllare.',
'catalog.modal.name_hint': 'slug', 'catalog.new.name': 'Nome',
'catalog.modal.scope': 'Ambito', 'catalog.new.name_hint': 'slug',
'catalog.modal.type': 'Tipo', 'catalog.new.scope': 'Ambito',
'catalog.modal.transport': 'Trasporto', 'catalog.new.type': 'Tipo',
'catalog.modal.command': 'Comando', 'catalog.new.transport': 'Trasporto',
'catalog.modal.command_ph': 'python3', 'catalog.new.command': 'Comando',
'catalog.modal.script_path': 'Percorso script', 'catalog.new.command_ph': 'python3',
'catalog.modal.script_path_hint': 'come <connettore>/<file>, sotto ./connectors', 'catalog.new.script_path': 'Percorso script',
'catalog.modal.url': 'URL', 'catalog.new.script_path_hint': 'come <connettore>/<file>, sotto ./connectors',
'catalog.modal.args': 'Argomenti', 'catalog.new.url': 'URL',
'catalog.modal.args_hint': 'uno per riga', 'catalog.new.args': 'Argomenti',
'catalog.modal.config_schema': 'Chiavi segrete/env richieste', 'catalog.new.args_hint': 'uno per riga',
'catalog.modal.config_schema_hint': 'virgola/nuova riga', 'catalog.new.config_schema': 'Chiavi segrete/env richieste',
'catalog.modal.auth': 'Auth', 'catalog.new.config_schema_hint': 'virgola/nuova riga',
'catalog.modal.friendly': 'Nome visualizzato', 'catalog.new.auth': 'Auth',
'catalog.modal.desc': 'Descrizione', 'catalog.new.friendly': 'Nome visualizzato',
'catalog.modal.desc_hint': 'l\'LLM legge questo quando decide se attivare il connettore', 'catalog.new.desc': 'Descrizione',
'catalog.modal.cancel': 'Annulla', 'catalog.new.desc_hint': 'l\'LLM legge questo quando decide se attivare il connettore',
'catalog.modal.save': 'Aggiungi al catalogo', 'catalog.new.cancel': 'Annulla',
'catalog.new.save': 'Aggiungi al catalogo',
'catalog.error.name': 'Il nome è obbligatorio.', 'catalog.error.name': 'Il nome è obbligatorio.',
'catalog.confirm.delete': 'Rimuovere "{name}" dal catalogo?\n\nTutto ciò che è già stato attivato continuerà a funzionare.', 'catalog.confirm.delete': 'Rimuovere "{name}" dal catalogo?\n\nTutto ciò che è già stato attivato continuerà a funzionare.',
@@ -1039,6 +1056,7 @@ export default {
'common.saving': 'Salvataggio…', 'common.saving': 'Salvataggio…',
'common.cancel': 'Annulla', 'common.cancel': 'Annulla',
'common.loading': 'Caricamento…', 'common.loading': 'Caricamento…',
'common.saved': 'Salvato',
// ── Cartelle condivise (blueprint §6) ──────────────────────────────────────── // ── Cartelle condivise (blueprint §6) ────────────────────────────────────────
'nav.shared_folders': 'Cartelle condivise', 'nav.shared_folders': 'Cartelle condivise',