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 @@ -101,7 +103,7 @@ jobs:
run: |
find "$HOME/.colima" -type f \( -name 'ha.stderr.log' -o -name 'serial*.log' -o -name 'qemu.stderr.log' \) -print -exec tail -n 150 {} \;
- name: Run the backend through Rust Core and Docker
run: node scripts/e2e.mjs
run: node scripts/e2e.mjs --source https://github.com/1lck/Lithe-IDEA.git --ref preview
- name: Stop the test engine
if: always()
run: colima stop || true
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ jobs:
run: |
find "$HOME/.colima" -type f \( -name 'ha.stderr.log' -o -name 'serial*.log' -o -name 'qemu.stderr.log' \) -print -exec tail -n 150 {} \;
- name: Run the backend through Rust Core and Docker
run: node scripts/e2e.mjs
run: node scripts/e2e.mjs --source https://github.com/1lck/Lithe-IDEA.git --ref preview
- name: Stop the test engine
if: always()
run: colima stop || true
Expand Down
36 changes: 32 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,33 @@ Elastic 自有仓库、Open WebUI、MinIO 和 pgvector 使用确切公开仓库
网页关闭只取消在途操作,已交付的终端继续由用户使用。未增加磁盘缓存或安装目录写入。
浏览器预览不具备 Lithe 原生终端服务,点击时明确提示不可用。

## 宿主日志接入

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

SDK 拥有每个函数唯一的成功或失败终态,携带注册函数 ID、单调耗时、安全分类
及原始 operation ID,不打印内部错误、panic、Docker 正文或命令输出。
列表、统计、日志流、事件轮询、图标和分块上传的成功诊断被显式抑制,失败仍记录。
`engine.info` 返回不可用状态时通过 `diagnostics().degraded` 报告固定分类;
一个原子健康状态抑制相同故障的重复探测,恢复只报告一次 `recovered`。
容器创建通过拉取缺失镜像恢复、Compose 删除保留仍被占用网络时,分别报告恢复
和带数量的降级。诊断不含容器名、镜像名、端点、环境、文件内容或容器输出。
协议通道建立前的启动失败以退出码交给宿主进程生命周期,不另行打印。

`vendor/lithe-plugin-sdk/` 固定同批官方 SDK 源码快照,Cargo 以相对路径引用;
其 `source-provenance.json` 保存真实来源基线和每个文件的 SHA-256,不伪造已发布
版本或 Git 提交。插件不强制要求 `${logDir}`,也不维护旧日志系统的兼容写入路径;
结构化通知需要支持新协议的宿主处理。

## 开发与验证

需要 Node.js 24+、Rust 1.87+ 和 macOS。官方 Rust SDK 锁定在
`sdk-rust-v0.1.0`,具体提交与依赖版本由 `backend/Cargo.lock` 固定。
需要 Node.js 24+、Rust 1.87+ 和 macOS。官方 Rust SDK 使用
`vendor/lithe-plugin-sdk/` 的源码快照,来源和文件哈希由 `source-provenance.json`
记录,其余依赖版本由 `backend/Cargo.lock` 固定。

```sh
npm ci --prefix ui
Expand Down Expand Up @@ -132,12 +155,12 @@ node scripts/package.mjs
预览关闭会清理暂存文件,下载成功后立即清理对应暂存。Lithe 内继续使用宿主原生文件对话框。
统计图随窗口调整但有高度上限,大窗口下方保留留白;采样间隔和布局仍由页面状态保存。

真实 Core + Docker 验证使用独立的稀疏检出,版本固定在 `e2e/lithe-core.json`,
真实 Core + Docker 验证使用独立的稀疏检出,显式选择支持新日志 API 的来源与提交,
不会写入开发者的 Lithe 工作区。先准备好测试镜像以免镜像下载耗尽操作截止时间:

```sh
docker pull alpine:3.21
node scripts/e2e.mjs
node scripts/e2e.mjs --source https://github.com/1lck/Lithe-IDEA.git --ref preview
```

CI 与发布流程的打包和真实 Docker 集成车道使用 `macos-15-intel`,与 Colima 上游的 macOS
Expand All @@ -157,3 +180,8 @@ Core 测试只验证请求交给宿主,不伪造或宣称浏览器或原生终
Docker 名称和标识属于 Docker, Inc.,本项目不是 Docker 官方产品。

License: Apache-2.0.

新的日志端到端验证要求 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 @@ -12,9 +12,8 @@ path = "src/main.rs"

[dependencies]
base64 = "0.22"
# The official SDK at its release tag; Cargo.lock records the commit and
# `cargo build --locked` refuses anything else.
lithe-plugin-sdk = { git = "https://github.com/Lithe-IDEA/lithe-plugin-docs", tag = "sdk-rust-v0.1.0" }
# The official SDK source snapshot and its verified provenance are vendored.
lithe-plugin-sdk = { path = "../vendor/lithe-plugin-sdk" }
reqwest = { version = "0.12.28", default-features = false, features = ["blocking", "rustls-tls"] }
serde_json = "1"
tar = "0.4"
Expand Down
7 changes: 4 additions & 3 deletions backend/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@ use lithe_plugin_sdk::Backend;
use std::path::PathBuf;

fn main() {
let mut backend = Backend::new("lithe-docker").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 owns the
// startup exit-code event rather than a second stderr writer.
std::process::exit(2);
}
};
let mut backend = Backend::new("lithe-docker").version(env!("CARGO_PKG_VERSION"));
service::Service::new(state, endpoint::Environment::current()).install(&mut backend);
std::process::exit(backend.listen());
}
Expand Down
78 changes: 76 additions & 2 deletions backend/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use serde_json::{json, Map};
use std::collections::{BTreeMap, HashMap, VecDeque};
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};

Expand All @@ -35,6 +36,8 @@ pub struct Service {
logos: crate::logos::Logos,
files: crate::files::Files,
native_terminals: crate::terminal::Terminals,
/// One fixed-size health state; repeated UI probes do not flood diagnostics.
engine_health: AtomicU8,
}

impl Service {
Expand All @@ -52,6 +55,7 @@ impl Service {
environment,
choice: Mutex::new(choice),
terminals: Mutex::default(),
engine_health: AtomicU8::new(0),
})
}

Expand Down Expand Up @@ -101,6 +105,29 @@ impl Service {
backend
.function(id, move |params, context| handler(&service, params, context))
.expect("valid function ID");
if matches!(
id,
"containers.list"
| "containers.logs"
| "containers.stats"
| "containers.top"
| "contexts.list"
| "engine.info"
| "events.next"
| "images.list"
| "images.logo"
| "networks.list"
| "objects.inspect"
| "system.df"
| "volumes.list"
| "files.list"
| "files.uploadBegin"
| "files.uploadChunk"
) {
backend
.suppress_success_diagnostics(id)
.expect("registered function ID");
}
}
}

Expand Down Expand Up @@ -180,7 +207,10 @@ impl Service {
fn info(&self, _: Value, context: &Context) -> Result<Value, FunctionError> {
let endpoint = match self.endpoint() {
Ok(endpoint) => endpoint,
Err(error) => return Ok(unreachable(None, &error)),
Err(error) => {
self.report_unreachable(context, &error);
return Ok(unreachable(None, &error));
}
};
let docker = Docker::new(endpoint.socket.clone());
let version = match docker.get(context, "/version") {
Expand All @@ -191,11 +221,20 @@ impl Service {
"docker.unreachable" | "docker.permissionDenied" | "docker.connectionLost"
) =>
{
self.report_unreachable(context, &error);
return Ok(unreachable(Some(&endpoint), &error));
}
Err(error) => return Err(error),
};
let info = docker.get(context, "/info")?;
// Normal successful probes are silent. Only report recovery from a
// previously observed degraded connection, without its endpoint.
if self.engine_health.swap(1, Ordering::Relaxed) > 1 {
context
.diagnostics()
.recovered("engine.info", "connection_restored", None)
.expect("registered operation and static diagnostic code");
}
Ok(json!({
"reachable": true,
"endpoint": endpoint.to_json(),
Expand All @@ -209,6 +248,24 @@ impl Service {
}))
}

fn report_unreachable(&self, context: &Context, error: &PluginError) {
let (state, code) = match error.code() {
"docker.contextNotFound" => (2, "context_not_found"),
"docker.unsupportedHost" => (3, "unsupported_host"),
"docker.permissionDenied" => (4, "permission_denied"),
"docker.connectionLost" => (5, "connection_lost"),
_ => (6, "unreachable"),
};
// engine.info returns this as a degraded successful result, so the
// SDK's function-error boundary has no failure to record.
if self.engine_health.swap(state, Ordering::Relaxed) != state {
context
.diagnostics()
.degraded("engine.info", code, None)
.expect("registered operation and controlled diagnostic code");
}
}

fn list_contexts(&self, _: Value, _: &Context) -> Result<Value, FunctionError> {
let contexts: Vec<Value> = endpoint::contexts(&self.environment)
.into_iter()
Expand Down Expand Up @@ -378,9 +435,11 @@ impl Service {
target.push_str(&format!("?name={}", encode(name)));
}
let docker = self.docker()?;
let mut pulled_missing_image = false;
let created = match docker.call(context, "POST", &target, Some(&body)) {
Err(FunctionError::Plugin(error)) if error.code() == "docker.notFound" => {
pull(&docker, context, image)?;
pulled_missing_image = true;
docker.call(context, "POST", &target, Some(&body))?
}
other => other?,
Expand All @@ -390,12 +449,19 @@ impl Service {
context.progress("Starting the container", None);
docker.call(context, "POST", &format!("/containers/{}/start", encode(&id)), None)?;
}
if pulled_missing_image {
context
.diagnostics()
.recovered("containers.create", "missing_image_pulled", None)
.expect("registered operation and static diagnostic code");
}
Ok(json!({ "id": id, "warnings": created["Warnings"] }))
}

/// Starts, stops, restarts, or removes every container of a Compose
/// project, found by its label; removing also removes its networks.
fn compose_action(&self, params: Value, context: &Context) -> Result<Value, FunctionError> {
let mut retained_networks = 0_u64;
let project = text(&params, "project")?;
let action = one_of(&params, "action", COMPOSE_ACTIONS)?;
let docker = self.docker()?;
Expand All @@ -421,13 +487,21 @@ impl Service {
let id = network["Id"].as_str().unwrap_or_default();
// A network another container still uses stays.
match docker.call(context, "DELETE", &format!("/networks/{}", encode(id)), None) {
Err(FunctionError::Plugin(error)) if error.code() == "docker.conflict" => {}
Err(FunctionError::Plugin(error)) if error.code() == "docker.conflict" => {
retained_networks += 1;
}
other => {
other?;
}
}
}
}
if retained_networks > 0 {
context
.diagnostics()
.degraded("compose.action", "networks_in_use", Some(retained_networks))
.expect("registered operation and bounded diagnostic count");
}
Ok(json!({ "containers": containers.len() }))
}

Expand Down
52 changes: 52 additions & 0 deletions e2e/docker_plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,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 @@ -343,6 +344,57 @@ impl Host {
.iter()
.find(|event| event["operationId"] == operation && event["type"] == "requestCompleted")
{
if !matches!(
function,
"containers.list"
| "containers.logs"
| "containers.stats"
| "containers.top"
| "contexts.list"
| "engine.info"
| "events.next"
| "images.list"
| "images.logo"
| "networks.list"
| "objects.inspect"
| "system.df"
| "volumes.list"
| "files.list"
| "files.uploadBegin"
| "files.uploadChunk"
) || 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/docker_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
Loading