feat(mcp): WhatsApp connector, archivable catalog, MCP connector config endpoint
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(®istry, &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, ®istry).await);
|
specs.push(crate::mcp::user_row_spec_resolved(r, &container, ®istry).await);
|
||||||
|
|||||||
+330
-749
File diff suppressed because it is too large
Load Diff
Generated
-2702
File diff suppressed because it is too large
Load Diff
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" })))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
+84
-42
@@ -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.modal.save')}</button>
|
<i class="bi bi-check-lg me-1"></i>${t('catalog.new.save')}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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>
|
||||||
${c.installed ? html`<span class="connector-chip connector-chip--ok">${t('marketplace.card.installed')}</span>` : nothing}
|
</div>
|
||||||
|
${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
@@ -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
@@ -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
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user