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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- run: node scripts/verify-sdk-source.mjs
- run: cargo fmt --check
- run: cargo clippy --locked -- -D warnings
- run: cargo build --release --locked
Expand Down
1 change: 0 additions & 1 deletion Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ publish = false
license = "Apache-2.0"

[dependencies]
lithe-plugin-sdk = { git = "https://github.com/Lithe-IDEA/lithe-plugin-docs", rev = "e689971c000c15a044df0502052fe7b2c1c6f84f" }
lithe-plugin-sdk = { path = "vendor/lithe-plugin-sdk" }
ide-http = { git = "https://github.com/l0ng-ai/openidea", rev = "3be40bd57c5718df39977c5f8b5d31f0ff3e692a" }
ide-core = { git = "https://github.com/l0ng-ai/openidea", rev = "3be40bd57c5718df39977c5f8b5d31f0ff3e692a" }
serde_json = "1"
Expand Down
5 changes: 3 additions & 2 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ Uses unchanged ide-http and ide-core from OpenIDEA (Apache-2.0):
https://github.com/l0ng-ai/openidea
Revision: 3be40bd57c5718df39977c5f8b5d31f0ff3e692a

Uses the official Lithe Rust plugin SDK (MIT):
Uses the official Lithe Rust plugin SDK (Apache-2.0):
https://github.com/Lithe-IDEA/lithe-plugin-docs
Revision: e689971c000c15a044df0502052fe7b2c1c6f84f
Source snapshot: vendor/lithe-plugin-sdk/source-provenance.json records the
real upstream baseline, local structured-logging changes, and file hashes.

Original copyright and license notices are retained in licenses/ in each
release package. DEPENDENCIES.json records Cargo dependencies and notices.
Expand Down
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Run that C ABI integration under an external 30-second process watchdog. `script

## Architecture and attribution

The native Lithe host owns installation, activation, Web bridge, process lifecycle and cancellation. This plugin owns its UI and a thin Rust function adapter. HTTP parsing, variable resolution and transport come from unchanged `ide-http` / `ide-core` in [OpenIDEA](https://github.com/l0ng-ai/openidea), revision `3be40bd57c5718df39977c5f8b5d31f0ff3e692a` (Apache-2.0). The [official Rust SDK](https://github.com/Lithe-IDEA/lithe-plugin-docs) is pinned at `e689971c000c15a044df0502052fe7b2c1c6f84f` (MIT).
The native Lithe host owns installation, activation, Web bridge, process lifecycle and cancellation. This plugin owns its UI and a thin Rust function adapter. HTTP parsing, variable resolution and transport come from unchanged `ide-http` / `ide-core` in [OpenIDEA](https://github.com/l0ng-ai/openidea), revision `3be40bd57c5718df39977c5f8b5d31f0ff3e692a` (Apache-2.0). The [official Rust SDK](https://github.com/Lithe-IDEA/lithe-plugin-docs) is a vendored source snapshot (Apache-2.0); `vendor/lithe-plugin-sdk/source-provenance.json` records its baseline, local changes, and file hashes.

This project's adapter and UI are Apache-2.0. Release packages retain original dependency license/notice texts and include `DEPENDENCIES.json` and `UPSTREAM.json`. Missing crate-distribution license files are supplemented from pinned upstream sources recorded in `licenses/SOURCES.json`. Upstream authors do not endorse this plugin.

Expand All @@ -89,3 +89,24 @@ This project's adapter and UI are Apache-2.0. Release packages retain original d
保留 Lithe 原生 Mac 工作台,在底部工具窗口提供独立 HTTP 面板。粘贴 `.http` 文本后可发送请求、取消、查看状态码/耗时/大小,切换正文与响应头,格式化 JSON。界面跟随宿主中英文和主题。首版仅支持 macOS;支持原生选择 `.http` / `.rest` 文件、请求高亮、会话历史、可拖动分栏、响应换行和原始内容保存。HTML 响应可先保存到当前工作区,再在编辑区预览。没有环境文件、正文文件引用、响应脚本或编辑器行内按钮;历史不会写入磁盘,只有显式保存或预览才写入选择的文件。原生文件对话框和编辑区 HTML 预览的完整验收仍待进行。

UI source is `ui/src/main.js`; translations live in `shared/i18n/`. `npm run build` bundles all editor code locally into `ui/main.js` and records bundled UI dependency licenses. Commit generated outputs; `npm run check` verifies the bundle, licenses and matching English/Chinese keys. Packaging copies those files verbatim and requires no runtime downloads.

## 宿主日志接入

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

SDK 在每个函数的最终返回边界记录一次成功或失败,包含注册的函数 ID、单调耗时
以及安全的错误分类,并关联宿主原始 operation ID;取消不当作失败。
高频 `requests.parse` 和预览能力探测的成功诊断被显式抑制。
响应预览因大小上限被截断时,`Context.diagnostics().degraded` 在本次后端生命周期
报告一次 `preview_truncated`;重复截断保持静默。
诊断不含 URL、请求或响应正文、头部、环境变量、凭据、文件路径或所选文件内容。
启动在协议通道建立前失败时以退出码交给宿主进程生命周期记录,不另行打印。

仓库内 `vendor/lithe-plugin-sdk/` 是同批官方 SDK 源码快照,Cargo 使用相对路径,
来源和逐文件 SHA-256 保存在其 `source-provenance.json`。这不是已发布的新版本或
虚构的 Git revision。插件不要求 `${logDir}`,也不自行落盘;结构化通知的展示与
持久化依赖支持新日志协议的宿主,不将旧宿主兼容路径作为本轮迁移目标。
3 changes: 2 additions & 1 deletion scripts/package.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import zipfile

root = Path(__file__).resolve().parents[1]
subprocess.run(["node", str(root / "scripts/verify-sdk-source.mjs")], check=True, timeout=30)
metadata = json.loads(subprocess.check_output(["cargo", "metadata", "--locked", "--format-version", "1", "--filter-platform", "aarch64-apple-darwin"], cwd=root, timeout=60))
destination = root / "dist"
destination.mkdir(exist_ok=True)
Expand Down Expand Up @@ -63,7 +64,7 @@
(package / "DEPENDENCIES.json").write_text(json.dumps(records, ensure_ascii=False, indent=2) + "\n")
(package / "UPSTREAM.json").write_text(json.dumps({
"openidea": "3be40bd57c5718df39977c5f8b5d31f0ff3e692a",
"lithePluginSdk": "e689971c000c15a044df0502052fe7b2c1c6f84f",
"lithePluginSdk": json.loads((root / "vendor/lithe-plugin-sdk/source-provenance.json").read_text()),
"cargoLockSha256": hashlib.sha256((root / "Cargo.lock").read_bytes()).hexdigest(),
"upstreamModifications": [],
}, indent=2) + "\n")
Expand Down
11 changes: 11 additions & 0 deletions scripts/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
core.lithe_core_execute_json.argtypes = [ctypes.c_char_p]
core.lithe_core_execute_json.restype = ctypes.c_void_p
core.lithe_core_free_string.argtypes = [ctypes.c_void_p]
diagnostics = []


def execute(command, payload):
Expand All @@ -35,6 +36,7 @@ def await_event(session, predicate):
observed = []
while time.monotonic() < deadline:
value = execute("lsp.waitEvents", {"sessionId": session, "timeoutMilliseconds": 200})
diagnostics.extend(execute("diagnostics.drainCoreEvents", {})["events"])
for event in value["events"]:
observed.append(event)
if predicate(event):
Expand All @@ -49,6 +51,7 @@ def await_event(session, predicate):
with tempfile.TemporaryDirectory() as directory:
started = execute("lsp.startServer", {
"providerId": "http-client", "pluginId": manifest["id"],
"role": "backend",
"executablePath": str(args.binary.resolve()), "arguments": [],
"rootUri": Path(directory).as_uri(), "workingDirectory": directory,
"initializeTimeoutMilliseconds": 5000, "requestTimeoutMilliseconds": 5000,
Expand Down Expand Up @@ -79,6 +82,14 @@ def await_event(session, predicate):
event = await_event(session, lambda event: event.get("type") == "requestCompleted" and event.get("operationId") == "pilot-http")
assert event["result"]["value"]["status"] == 200, event
assert json.loads(event["result"]["value"]["body"]) == {"ok": True}, event
diagnostics.extend(execute("diagnostics.drainCoreEvents", {})["events"])
finished = [entry for entry in diagnostics if entry["event"] == "plugins.operation.finished" and entry.get("op") == "pilot-http"]
assert len(finished) == 1, diagnostics
assert finished[0]["level"] == "INFO", finished
assert finished[0]["channel"] == f"plugins/{manifest['id']}/host", finished
assert finished[0]["fields"]["operation"] == "requests.execute", finished
assert finished[0]["fields"]["duration_ms"] >= 0, finished
assert not any(entry["event"] == "plugins.operation.finished" and entry.get("op") == "pilot-parse" for entry in diagnostics)
finally:
server.shutdown()
server.server_close()
Expand Down
24 changes: 23 additions & 1 deletion scripts/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def __init__(self, directory):
self.selector.register(self.process.stdout, selectors.EVENT_READ)
self.buffer = b""
self.number = 0
self.diagnostics = []

def send(self, method, params, request=True):
self.number += 1
Expand All @@ -52,6 +53,8 @@ def answer(self, number, timeout=5):
if len(body) >= length:
frame = json.loads(body[:length])
self.buffer = body[length:]
if frame.get("method") == "lithe/log":
self.diagnostics.append(frame["params"])
if frame.get("id") == number and "method" not in frame:
return frame
continue
Expand Down Expand Up @@ -117,9 +120,10 @@ def setUp(self):
self.addCleanup(self.host.close)
initialized = self.host.answer(self.host.send("initialize", {
"rootUri": Path(self.temp.name).as_uri(),
"capabilities": {"experimental": {"litheFunctions": {"versions": [1, 2, 3]}}}
"capabilities": {"experimental": {"litheFunctions": {"versions": [1, 2, 3]}, "litheLogging": {"versions": [1]}}}
}))
self.assertIn("result", initialized)
self.assertEqual(initialized["result"]["capabilities"]["experimental"]["litheLogging"]["version"], 1)
self.host.send("initialized", {}, request=False)
self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self.server.daemon_threads = True
Expand Down Expand Up @@ -183,6 +187,17 @@ def test_unicode_response_preview_is_bounded(self):
self.assertTrue(value["truncated"])
self.assertLessEqual(len(value["body"].encode()), 48 * 1024)
self.assertNotIn("\ufffd", value["body"])
# The real SDK sends typed diagnostics over its framed output. No
# request or response contents enter those diagnostic fields.
degraded = [entry for entry in self.host.diagnostics if entry["kind"] == "degraded"]
self.assertEqual(len(degraded), 1)
self.assertEqual(degraded[0]["operation"], "requests.execute")
self.assertEqual(degraded[0]["code"], "preview_truncated")
self.assertEqual(degraded[0]["operationId"], "op-3")
log = json.dumps(self.host.diagnostics)
self.assertNotIn(self.url, log)
self.assertNotIn("好🙂", log)
self.assertEqual(os.pread(self.host.log.fileno(), 65536, 0), b"")

def test_configurable_preview_and_default(self):
for limit in [1, 49, 256, 512]:
Expand All @@ -194,6 +209,8 @@ def test_configurable_preview_and_default(self):
self.assertGreater(len(value["body"].encode()), 127 * 1024)
self.assertLessEqual(len(value["body"].encode()), 128 * 1024)
self.assertTrue(value["truncated"])
degraded = [entry for entry in self.host.diagnostics if entry["kind"] == "degraded"]
self.assertEqual(len(degraded), 1)

def test_escaped_preview_respects_transport_budget(self):
value = self.host.answer(self.host.invoke("requests.execute", {"text": f"GET {self.url}/escaped", "index": 0, "previewLimitKiB": 512}))["result"]
Expand All @@ -209,6 +226,11 @@ def test_invalid_preview_limit_is_rejected(self):
def test_no_state_files_written(self):
self.assertEqual(self.execute(f"GET {self.url}/ok")["result"]["status"], 200)
self.assertEqual(list(Path(self.temp.name).iterdir()), [])
self.assertEqual(os.pread(self.host.log.fileno(), 65536, 0), b"")
self.assertEqual(len(self.host.diagnostics), 1)
self.assertEqual(self.host.diagnostics[0]["kind"], "operationFinished")
self.assertEqual(self.host.diagnostics[0]["operation"], "requests.execute")
self.assertGreaterEqual(self.host.diagnostics[0]["durationMs"], 0)


class TimedResult(unittest.TextTestResult):
Expand Down
2 changes: 1 addition & 1 deletion scripts/test_ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const escape = value => String(value).replace(/[<>&"']/g, c => ({ '<': '&lt;', '
}
});
backend.on('error', error => { for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(error); } pending.clear(); });
await request('initialize', { rootUri: new URL(`file://${temporary}/`).href, capabilities: { experimental: { litheFunctions: { versions: [1, 2, 3] } } } }).result;
await request('initialize', { rootUri: new URL(`file://${temporary}/`).href, capabilities: { experimental: { litheFunctions: { versions: [1, 2, 3] }, litheLogging: { versions: [1] } } } }).result;
send({ method: 'initialized', params: {} });
const bridgeSource = `window.lithe = { version: 1, context: { locale: new URL(location.href).searchParams.get('locale') || 'en', platform: 'macos' }, request: async (method, params) => {
const response = await fetch('/bridge', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({method,params})});
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.`);
1 change: 1 addition & 0 deletions src/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ pub fn register(backend: &mut Backend) -> Result<(), lithe_plugin_sdk::SdkError>
preview_context(context)?;
Ok(json!({"available": true}))
})?;
backend.suppress_success_diagnostics("response.previewInfo")?;
backend.function("response.save", |params, context| {
context.check_cancelled()?;
let text = params["text"]
Expand Down
42 changes: 37 additions & 5 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
//! Thin Lithe plugin adapter around OpenIDEA's unchanged HTTP parser and executor.
use ide_core::CancellationToken;
use ide_http::{env::Environments, exec, parse};
use lithe_plugin_sdk::{Backend, FunctionError, PluginError, Value};
use lithe_plugin_sdk::{Backend, FunctionError, PluginError, SdkError, Value};
use serde_json::json;
use std::{sync::Arc, time::Duration};
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};

mod files;

Expand All @@ -22,15 +28,35 @@ fn source(params: &Value) -> Result<&str, FunctionError> {
.ok_or_else(|| invalid("httpClient.invalidInput"))
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
let runtime = Arc::new(tokio::runtime::Runtime::new()?);
fn main() {
let backend = match prepare_backend() {
Ok(backend) => backend,
// A setup failure precedes the protocol writer. Let the host record
// the process exit instead of Rust's stderr Termination formatter.
Err(_) => std::process::exit(2),
};
std::process::exit(backend.listen());
}

fn prepare_backend() -> Result<Backend, SdkError> {
let mut backend = Backend::new("lithe-http-client").version(env!("CARGO_PKG_VERSION"));
let runtime = match tokio::runtime::Runtime::new() {
Ok(runtime) => Arc::new(runtime),
Err(_) => {
// The SDK's protocol output does not exist before listen. The
// host records this startup exit through its process lifecycle.
std::process::exit(1);
}
};
files::register(&mut backend)?;
backend.function("requests.parse", |params, context| {
context.check_cancelled()?;
let parsed = parse::parse(source(&params)?);
Ok(json!({"requests": parsed.requests.into_iter().map(|r| r.block).collect::<Vec<_>>()}))
})?;
backend.suppress_success_diagnostics("requests.parse")?;
// Record loss of preview data once per backend, without response contents.
let truncation_reported = AtomicBool::new(false);
backend.function("requests.execute", move |params, context| {
context.check_cancelled()?;
let preview_kib = match params.get("previewLimitKiB") {
Expand Down Expand Up @@ -107,6 +133,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let value = serde_json::to_value(&response)?;
let encoded_size = serde_json::to_vec(&value)?.len();
if encoded_size <= RESULT_LIMIT {
if response.truncated && !truncation_reported.swap(true, Ordering::Relaxed) {
context
.diagnostics()
.degraded("requests.execute", "preview_truncated", None)
.expect("registered operation and static diagnostic code");
}
return Ok(value);
}
let Some(body) = response.body.as_mut().filter(|body| !body.is_empty()) else {
Expand All @@ -121,5 +153,5 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
response.truncated = true;
}
})?;
std::process::exit(backend.listen());
Ok(backend)
}
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