Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ jobs:
node-version: 24
cache: npm
cache-dependency-path: ui/package-lock.json
- name: Verify SDK source provenance
run: node scripts/verify-sdk-source.mjs
- name: Install page dependencies
working-directory: ui
run: npm ci
Expand Down Expand Up @@ -87,7 +89,7 @@ jobs:
npm ci --prefix ui
node scripts/package.mjs
- name: Run the backend through Rust Core
run: node scripts/e2e.mjs
run: node scripts/e2e.mjs --source https://github.com/1lck/Lithe-IDEA.git --ref preview
- name: Check the archive holds the package
run: |
id=$(node -p "require('./plugin.json').id")
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:
- name: Build the package
run: node scripts/package.mjs
- name: Run the backend through Rust Core
run: node scripts/e2e.mjs
run: node scripts/e2e.mjs --source https://github.com/1lck/Lithe-IDEA.git --ref preview
- name: Verify the archive users download
run: |
id=$(node -p "require('./plugin.json').id")
Expand Down
32 changes: 30 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,9 @@ cargo clippy --locked --manifest-path backend/Cargo.toml --all-targets -- -D war
`--host-only` builds only this Mac's architecture. Install the folder or
the zip in Lithe with **Install from Disk** to try it.

`node scripts/e2e.mjs` runs `e2e/database_plugin.rs` against Lithe's Rust
Core at the commit `e2e/lithe-core.json` pins: Core launches the packaged
`node scripts/e2e.mjs --source <repository URL or path> --ref <revision>` runs
`e2e/database_plugin.rs` against an explicit Lithe Core with `litheLogging` v1.
Core launches the packaged
backend as a host does, and the test answers its credential requests and
drives a SQLite connection through every page function. `--source <path>`
uses a local Lithe checkout instead of cloning.
Expand All @@ -112,3 +113,30 @@ otherwise.
## License

Apache-2.0

## 宿主日志接入

后端使用官方 Rust SDK 的结构化 `lithe/log` 通知,协商
`experimental.litheLogging` v1,不另建文件写入器、后台任务或 stderr 回退。
macOS 宿主通过共享事件目录把后端事实写入会话通道
`plugins/<plugin-id>/host.log`,统一决定级别、脱敏、限长、轮转和保留。
stdout 保持协议帧专用;日志不写插件包或 app bundle,不影响签名和增量更新。

每个函数的最终成功或失败只由 SDK 记录一次,携带注册函数 ID、单调耗时、稳定
安全分类及宿主原始 operation ID;取消不当作失败。列表和历史抑制成功诊断;
`database.call` 按已经验证的方法区分,metadata、分页浏览和探测只抑制当前调用的
成功,显式查询和写入保留 INFO 终态,所有失败和真实降级继续记录。
返回行因插件传输限额被裁剪时,`diagnostics().degraded` 在本次后端生命周期内
记录一次 `result_size_limited`,重复裁剪保持静默。
诊断不含 SQL、连接串、服务器地址、用户名、密码、返回行、配置内容或文件路径。
通道建立前的启动失败通过进程退出码由宿主记录,不输出异步运行时错误正文。

`vendor/lithe-plugin-sdk/` 为同批官方 SDK 源码快照,Cargo 只使用仓库相对路径。
真实基线与逐文件 SHA-256 见 `source-provenance.json`,不伪造已发布版本或 Git SHA。
插件不强制要求 `${logDir}`,也不保留旧日志兼容写入器;需要支持新日志协议的宿主
负责结构化通知的展示和持久化。

新的日志端到端验证要求 Core 已包含 `litheLogging` v1;`scripts/e2e.mjs` 必须显式传入
`--source` 和 `--ref`,不再默认运行旧的 `e2e/lithe-core.json` 基线。CI 明确选择主仓
`preview`,需要先合并宿主日志 API;跨仓发布时再把验证引用固定到合并后的精确提交。
宿主改动未合并时这项检查会真实失败,不跳过,也不把旧协议运行当作新 API 验收。
1 change: 0 additions & 1 deletion backend/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 2 additions & 3 deletions backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@ publish = false

[workspace.dependencies]
serde_json = "1"
# The official SDK, pinned to a commit; Cargo.lock records it and
# `cargo build --locked` refuses anything else.
lithe-plugin-sdk = { git = "https://github.com/Lithe-IDEA/lithe-plugin-docs", rev = "5da0524f4751425c216bf5162c9fc1eb1dddd22f" }
# The official SDK source snapshot and its verified provenance are vendored.
lithe-plugin-sdk = { path = "../vendor/lithe-plugin-sdk" }

[profile.release]
lto = "thin"
Expand Down
10 changes: 5 additions & 5 deletions backend/crates/backend/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ use lithe_plugin_sdk::Backend;
use std::path::PathBuf;

fn main() {
let mut backend = Backend::new("lithe-database").version(env!("CARGO_PKG_VERSION"));
let state = match state_directory() {
Ok(state) => state,
Err(message) => {
eprintln!("{message}");
Err(_) => {
// Before listen there is no protocol output. The host records
// this startup exit through the process lifecycle.
std::process::exit(2);
}
};
Expand All @@ -26,12 +28,10 @@ fn main() {
.build()
{
Ok(runtime) => runtime,
Err(error) => {
eprintln!("The async runtime could not start: {error}");
Err(_) => {
std::process::exit(1);
}
};
let mut backend = Backend::new("lithe-database").version(env!("CARGO_PKG_VERSION"));
service::Service::new(store::Store::new(state), runtime).install(&mut backend);
std::process::exit(backend.listen());
}
Expand Down
63 changes: 58 additions & 5 deletions backend/crates/backend/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use serde_json::json;
use std::fs;
use std::io::{self, Read, Write};
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::runtime::Runtime;
use tokio_util::sync::CancellationToken;
Expand Down Expand Up @@ -114,11 +115,17 @@ const KINDS: &[&str] = &[
pub struct Service {
store: Store,
runtime: Runtime,
/// A bounded warning flag; repeated large queries do not flood diagnostics.
result_limit_reported: AtomicBool,
}

impl Service {
pub fn new(store: Store, runtime: Runtime) -> Arc<Self> {
Arc::new(Self { store, runtime })
Arc::new(Self {
store,
runtime,
result_limit_reported: AtomicBool::new(false),
})
}

/// Declares every function on `backend`.
Expand Down Expand Up @@ -147,6 +154,16 @@ impl Service {
declare(backend, "history.append", Self::append_history);
declare(backend, "connections.previewDBX", Self::preview_dbx);
declare(backend, "connections.importDBX", Self::import_dbx);
for id in [
"workspace.load",
"history.list",
"history.append",
"connections.previewDBX",
] {
backend
.suppress_success_diagnostics(id)
.expect("registered function ID");
}
}

/// `{ capabilities, profiles, folders }`; profiles never carry passwords.
Expand Down Expand Up @@ -288,8 +305,31 @@ impl Service {
if !ENGINE_METHODS.contains(&method) {
return Err(invalid(format!("{method:?} is not a database method.")).into());
}
// One public function serves both explicit queries and UI browsing.
// Keep query/write results at INFO; only this invocation's successful
// read/probe is quiet, while its failures and degradations still emit.
if matches!(
method,
"checkWriteProtection"
| "describeTable"
| "diagnostics"
| "listDatabases"
| "listForeignKeys"
| "listIndexes"
| "listObjects"
| "listTables"
| "pageTable"
| "nacosGetConfig"
| "nacosListConfigs"
| "nacosListInstances"
| "nacosListServices"
| "redisGetKey"
| "redisScan"
) {
context.suppress_success_diagnostics();
}
let value = self.run_on_profile(&params, method, params["params"].clone(), context)?;
Ok(fit_result(value)?)
self.fit_result(value, context)
}

/// Runs an export method on `profileID` and writes the file it produced
Expand Down Expand Up @@ -326,7 +366,21 @@ impl Service {
};
method_params.insert("data".into(), Value::String(BASE64.encode(bytes)));
let value = self.run_on_profile(&params, method, Value::Object(method_params), context)?;
Ok(fit_result(value)?)
self.fit_result(value, context)
}

fn fit_result(&self, value: Value, context: &Context) -> Result<Value, FunctionError> {
let value = fit_result(value)?;
if value["sizeLimited"] == true && !self.result_limit_reported.swap(true, Ordering::Relaxed)
{
// This is a degraded successful result. Failed calls remain owned
// by the SDK, so the same error is not recorded twice.
context
.diagnostics()
.degraded(context.function_id(), "result_size_limited", None)
.expect("registered operation and static diagnostic code");
}
Ok(value)
}

/// Runs `method` on the saved profile `call.profileID` with its current
Expand Down Expand Up @@ -584,8 +638,7 @@ fn engine_failure(code: &str, message: String) -> PluginError {
pub(crate) fn credential_failure(error: SdkError) -> FunctionError {
if error.code() == sdk_codes::CALL_CANCELLED {
FunctionError::Cancelled
} else if error.code() == sdk_codes::NOT_NEGOTIATED || error.code().starts_with("plugin.host.")
{
} else if error.code().starts_with("plugin.host.") {
PluginError::new(
"database.credentialsUnavailable",
format!(
Expand Down
40 changes: 40 additions & 0 deletions e2e/database_plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ impl Host {
json!({
"providerId": format!("pluginBackend:{plugin_id}"),
"pluginId": plugin_id,
"role": "backend",
"executablePath": executable,
"arguments": plan["arguments"],
"rootUri": format!("file://{}/", state.display()),
Expand Down Expand Up @@ -387,6 +388,45 @@ impl Host {
.iter()
.find(|event| event["operationId"] == operation && event["type"] == "requestCompleted")
{
if !matches!(
function,
"workspace.load"
| "history.list"
| "history.append"
| "connections.previewDBX"
) || completed.get("error").is_some()
{
let records = core("diagnostics.drainCoreEvents", json!({}));
let event = if completed.get("error").is_some() {
"plugins.operation.failed"
} else {
"plugins.operation.finished"
};
let terminal: Vec<_> = records["events"]
.as_array()
.unwrap()
.iter()
.filter(|record| record["event"] == event && record["op"] == operation)
.collect();
assert_eq!(
terminal.len(),
1,
"typed terminal for {operation}: {records}"
);
assert_eq!(
terminal[0]["channel"],
format!("plugins/{}/host", self.plugin_id)
);
assert_eq!(
terminal[0]["level"],
if completed.get("error").is_some() {
"ERROR"
} else {
"INFO"
}
);
assert_eq!(terminal[0]["fields"]["operation"], function);
}
return completed.clone();
}
let remaining = deadline.saturating_duration_since(Instant::now());
Expand Down
12 changes: 7 additions & 5 deletions scripts/e2e.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
// Runs e2e/database_plugin.rs against Lithe's Rust Core with the package
// scripts/package.mjs staged in dist/.
//
// node scripts/e2e.mjs [--ref <commit|branch>] [--source <Lithe git URL or path>]
// node scripts/e2e.mjs --ref <commit|branch> --source <Lithe git URL or path>
//
// Core lives in the Lithe repository, so the scenario is copied into
// rust/lithe-core/tests/ of a disposable Lithe checkout in .artifacts/lithe
// (only rust/ and shared/ are checked out) and run there with cargo. By
// default that checkout is the commit e2e/lithe-core.json pins.
// (only rust/ and shared/ are checked out) and run there with cargo.
// Both --source and --ref are required; Core must support litheLogging v1.
import { spawn } from 'node:child_process';
import { access, copyFile, mkdir, readFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
Expand Down Expand Up @@ -41,7 +41,9 @@ async function checkoutCore(source, ref) {
}

const { values } = parseArgs({ options: { ref: { type: 'string' }, source: { type: 'string' } } });
const pin = JSON.parse(await readFile(join(root, 'e2e/lithe-core.json'), 'utf8'));
if (!values.source || !values.ref) {
throw new Error('Structured plugin logging requires an explicit new Core checkout: pass --source <repository URL or path> --ref <commit or branch> containing litheLogging v1.');
}
const manifest = JSON.parse(await readFile(join(root, 'plugin.json'), 'utf8'));
const pkg = join(root, 'dist', manifest.id);
try {
Expand All @@ -52,7 +54,7 @@ try {

// git runs inside the checkout, so a local source must be absolute.
const source = values.source && !/^[a-z]+:\/\//.test(values.source) ? resolve(values.source) : values.source;
await checkoutCore(source ?? `${pin.repository}.git`, values.ref ?? pin.revision);
await checkoutCore(source, values.ref);
await copyFile(join(root, 'e2e', `${TEST}.rs`), join(work, 'rust/lithe-core/tests', `${TEST}.rs`));
await run('cargo', ['test', '--locked', '--manifest-path', join(work, 'rust/Cargo.toml'), '-p', 'lithe-core',
'--test', TEST, '--', '--nocapture'], {
Expand Down
2 changes: 2 additions & 0 deletions scripts/package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

await import('./verify-sdk-source.mjs');

const root = fileURLToPath(new URL('..', import.meta.url));
const BINARY = 'lithe-database-backend';
const TARGETS = { arm64: 'aarch64-apple-darwin', x86_64: 'x86_64-apple-darwin' };
Expand Down
31 changes: 31 additions & 0 deletions scripts/verify-sdk-source.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Verifies the reviewed official SDK source snapshot before builds/packages.
import { createHash } from 'node:crypto';
import { readdir, readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';

const directory = fileURLToPath(new URL('../vendor/lithe-plugin-sdk/', import.meta.url));
const provenance = JSON.parse(await readFile(join(directory, 'source-provenance.json'), 'utf8'));
if (provenance.repository !== 'https://github.com/Lithe-IDEA/lithe-plugin-docs'
|| !/^[a-f0-9]{40}$/.test(provenance.baseRevision)
|| typeof provenance.localChanges !== 'string' || !provenance.localChanges.trim()) {
throw new Error('Invalid SDK source provenance.');
}
const actual = [];
async function inspect(relative = '') {
for (const entry of await readdir(join(directory, relative), { withFileTypes: true })) {
const name = relative ? `${relative}/${entry.name}` : entry.name;
if (entry.isSymbolicLink()) throw new Error(`SDK snapshot contains a symbolic link: ${name}`);
if (entry.isDirectory()) await inspect(name);
else if (entry.isFile() && name !== 'source-provenance.json') actual.push(name);
else if (!entry.isFile()) throw new Error(`SDK snapshot contains a special file: ${name}`);
}
}
await inspect();
const expected = Object.keys(provenance.files).sort();
if (JSON.stringify(actual.sort()) !== JSON.stringify(expected)) throw new Error('SDK snapshot file list differs from provenance.');
for (const name of expected) {
const digest = createHash('sha256').update(await readFile(join(directory, name))).digest('hex');
if (digest !== provenance.files[name]) throw new Error(`SDK source hash differs: ${name}`);
}
console.log(`Verified ${expected.length} SDK source files against ${provenance.baseRevision} plus declared local changes.`);
11 changes: 11 additions & 0 deletions vendor/lithe-plugin-sdk/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[package]
name = "lithe-plugin-sdk"
description = "Official SDK for Lithe plugin backends: plugin functions and host services over the LSP base protocol."
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"
rust-version = "1.87"
publish = false

[dependencies]
serde_json = "1"
Loading
Loading