Release v1.121.0

This commit is contained in:
coso
2026-08-05 18:10:06 +08:00
parent bba332c65b
commit c400c8f7a1
203 changed files with 16095 additions and 557 deletions
+19 -5
View File
@@ -1,23 +1,37 @@
## Lime v1.120.1
## Lime v1.121.0
Simplified Chinese release notes are the primary version.
### New Features
- Introduced the Plugin v2 current path. App Center can discover Codex-compatible plugins from bundled, repository, personal, and local-directory catalogs, then review, install, enable, disable, and uninstall them.
- Enabled installed plugins in the current Agent runtime through immutable activation snapshots, including structured `plugin://` mentions in Claw, Skill/MCP injection, MCP App Right Surface rendering, and history restoration.
- Added a controlled Electron WebContentsView HTML host and bundled Browser Plugin assets, and included plugin assets in the Forge packaging path.
### Fixes
- Prevented internal provenance metadata from ordinary runtime Thread Items crossing the v2 projection boundary, so public items such as `contextCompaction` no longer expose private fields such as `sourceEventType`; imported history keeps its controlled typed metadata.
- Fixed Plugin MCP provider namespaces being treated as canonical server identities, so resource reads, tool traces, Right Surface rendering, and reload recovery use one stable identity.
- Fixed Skills watcher changes being dropped inside the throttle window by notifying on the leading edge and coalescing a trailing update.
- Fixed stale session-switch, initial-navigation, and Plugin Right Surface state, and made uninstalled plugins retain history without restarting runtimes or replaying tools.
- Fixed canonical history persistence merging already-materialized snapshots a second time, which duplicated reasoning deltas, dropped repeated fragments, and regressed large Codex history imports; added bounded waits for transient SQLite lock contention.
### Improvements and Refactoring
- Consolidated Plugin catalog, installed/enabled state, package validation, and installation transactions in the App Server current owner; Renderer access now goes through the typed JSON-RPC gateway.
- Improved MCP resource provenance, canonical Thread/Turn/Item projection, and Agent Workspace Right Surface hosting while removing production dependence on the renderer legacy registry and Plugin worker.
### Testing and Quality
- Added projection library coverage and a fork-compaction JSON-RPC integration regression for the metadata boundary and replacement/tail recovery after restart.
- Added Rust, Vitest, contract, and real Electron Gate B coverage for Plugin v2 App Center, typed gateway, mention/activation, MCP resources, Right Surface, history restoration, and uninstall semantics.
- Added regression guards for Electron packaged assets, embedded Browser HTML, GUI smoke, Skills watcher behavior, and the tool lifecycle.
- Added canonical reasoning linear-persistence and 1,200-command history-import performance regressions covering repeated deltas, final snapshot replacement, and background progress completion.
### Documentation
- Added Plugin v2 product contracts, installation model, command boundaries, App Center/Claw surfaces, migration cleanup, verification plans, and the corresponding current architecture diagrams.
### Other
- Bumped the root app, CLI npm package, Rust workspace, and lockfile versions to `1.120.1`.
- Bumped the root app, CLI npm package, Rust workspace, and lockfile versions to `1.121.0`.
**Full changes**: `v1.120.0` -> `v1.120.1`
**Full changes**: `v1.120.1` -> `v1.121.0`
+19 -5
View File
@@ -1,21 +1,35 @@
## Lime v1.120.1
## Lime v1.121.0
### 新功能
- 推出 Plugin v2 current 主链:App Center 可从 bundled、仓库、个人与本地目录 catalog 发现 Codex-compatible Plugin,完成详情查看、安装确认、启停与卸载。
- 已安装 Plugin 通过不可变 activation snapshot 进入当前 Agent runtime,支持 Claw 结构化 `plugin://` mention、Skill/MCP 注入、MCP App Right Surface 与历史恢复。
- 新增受控的 Electron WebContentsView HTML 宿主与 bundled Browser Plugin 资产,并将 Plugin 资产纳入 Forge 打包链路。
### 修复
- 修复普通运行时 Thread Item 的内部 provenance metadata 穿过 v2 投影边界,避免 `contextCompaction` 等公开 item 泄漏 `sourceEventType` 等私有字段;历史导入项仍保留受控的 typed metadata
- 修复 Plugin MCP provider namespace 被误当作 canonical server identity 的问题,使资源读取、tool trace、Right Surface 与 reload 恢复使用同一稳定 identity
- 修复 Skills watcher 在 throttle 窗口内丢失变更,改为首次立即通知并合并尾沿更新。
- 修复会话切换、初始导航与 Plugin Right Surface 的过期状态,并确保 Plugin 卸载后只保留历史证据,不重启 runtime 或重放 tool。
- 修复 canonical history 持久化对已物化快照二次 merge 导致的 reasoning delta 重复、重复片段丢失与 Codex 大历史导入退化,并为 SQLite 短暂锁竞争增加有界等待。
### 优化与重构
- 将 Plugin catalog、installed/enabled state、包校验与安装事务收口到 App Server current ownerRenderer 统一通过 typed JSON-RPC gateway 访问。
- 完善 MCP resource provenance、canonical Thread/Turn/Item 投影与 Agent Workspace 右侧承载,移除对 renderer 旧 registry 和 Plugin worker 的生产依赖。
### 测试与质量
- 补充 projection library 单测与 fork-compaction JSON-RPC 集成回归,确认 metadata 边界和重启后的替换/尾部恢复行为
- 新增 Plugin v2 App Center、typed gateway、mention/activation、MCP resource、Right Surface、历史恢复与卸载语义的 Rust、Vitest、contract 和真实 Electron Gate B 覆盖
- 补充 Electron 打包资产、内嵌 Browser HTML、GUI smoke、Skills watcher 和 tool lifecycle 的回归守卫。
- 新增 canonical reasoning 线性持久化与 1,200 条命令历史导入性能回归,覆盖重复 delta、final 快照替换和后台进度完成。
### 文档
- 新增 Plugin v2 产品合同、安装模型、命令边界、App Center/Claw Surface、迁移清理与验收计划,并同步 current 架构图。
### 其他
- 将根应用、CLI npm 包、Rust workspace 与锁文件版本统一提升到 `1.120.1`
- 将根应用、CLI npm 包、Rust workspace 与锁文件版本统一提升到 `1.121.0`
**完整变更**: `v1.120.0` -> `v1.120.1`
**完整变更**: `v1.120.1` -> `v1.121.0`
@@ -0,0 +1,20 @@
{
"name": "openai-bundled",
"interface": {
"displayName": "Lime Bundled"
},
"plugins": [
{
"name": "browser",
"source": {
"source": "local",
"path": "./plugins/browser"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_USE"
},
"category": "Productivity"
}
]
}
@@ -0,0 +1,13 @@
---
name: control-browser
description: Research and inspect pages through Lime's managed browser session.
---
# Managed Browser
Use the existing Lime browser session for page inspection and navigation tasks.
- Prefer the current Lime browser session and its Right Surface projection.
- Keep browser intent and the resulting tool item associated with the current thread.
- Ask for confirmation before external writes, account changes, or irreversible actions.
- Do not claim that a page was opened or changed unless the browser tool returns a terminal result.
+29
View File
@@ -708,6 +708,10 @@ async function resolveAppServerSidecarEnv(
// AppDataRoot 由 Host 显式解析后下发;App Server 的叶子 writer 不再自行猜平台根。
APP_SERVER_APP_DATA_DIR: resolveAppServerAppDataRoot(),
};
const bundledMarketplacePath = resolveBundledPluginMarketplacePath();
if (bundledMarketplacePath) {
env.LIME_BUNDLED_PLUGIN_MARKETPLACE = bundledMarketplacePath;
}
const currentNoProxy = APP_SERVER_NO_PROXY_ENV_KEYS.map(
(key) => process.env[key],
).find((value) => Boolean(value?.trim()));
@@ -731,6 +735,31 @@ async function resolveAppServerSidecarEnv(
return Object.keys(env).length > 0 ? env : undefined;
}
function resolveBundledPluginMarketplacePath(): string | undefined {
const resourceRoots = [
process.resourcesPath,
path.resolve(app.getAppPath(), "dist-electron"),
];
for (const resourceRoot of resourceRoots) {
const marketplacePath = path.join(
resourceRoot,
"plugins",
"openai-bundled",
".agents",
"plugins",
"marketplace.json",
);
try {
if (readFileSync(marketplacePath, "utf8")) {
return marketplacePath;
}
} catch {
// 开发态或旧包可能没有 bundled marketplaceApp Server 继续发现其他来源。
}
}
return undefined;
}
function resolveAppServerRuntimeLibraryEnv(
binaryPath: string,
): NodeJS.ProcessEnv {
+205 -6
View File
@@ -166,6 +166,10 @@ vi.mock("./electronRuntime", () => ({
function createWindow() {
let destroyed = false;
const windowEventHandlers = new Map<
string,
Array<(...args: unknown[]) => void>
>();
const windowWebContentsEventHandlers = new Map<
string,
Array<(...args: unknown[]) => void>
@@ -184,9 +188,24 @@ function createWindow() {
handler(...args);
}
},
emitWindowEventForTest: (eventName: string, ...args: unknown[]) => {
for (const handler of windowEventHandlers.get(eventName) || []) {
handler(...args);
}
},
isDestroyed: () => destroyed,
off: vi.fn(),
on: vi.fn(),
off: vi.fn((eventName: string, handler: (...args: unknown[]) => void) => {
const handlers = windowEventHandlers.get(eventName) || [];
windowEventHandlers.set(
eventName,
handlers.filter((item) => item !== handler),
);
}),
on: vi.fn((eventName: string, handler: (...args: unknown[]) => void) => {
const handlers = windowEventHandlers.get(eventName) || [];
handlers.push(handler);
windowEventHandlers.set(eventName, handlers);
}),
webContents: {
off: vi.fn((eventName: string, handler: (...args: unknown[]) => void) => {
const handlers = windowWebContentsEventHandlers.get(eventName) || [];
@@ -329,7 +348,88 @@ describe("ElectronEmbeddedBrowserHost", () => {
).toBe(true);
});
it("窗口关闭后销毁内嵌浏览器不访问已销毁 BrowserWindow", async () => {
it("通过受控命令加载 MCP App HTML,并保持 ui source URI", async () => {
resetMocks();
const host = new ElectronEmbeddedBrowserHost();
const window = createWindow();
await host.invoke(window as never, "embedded_browser_view_mount", {
viewId: "mcp-app-1",
bounds: { x: 0, y: 0, width: 480, height: 640 },
});
await expect(
host.invoke(window as never, "embedded_browser_view_load_html", {
viewId: "mcp-app-1",
html: "<!doctype html><title>Plugin report</title>",
source: "mcpApp",
sourceUri: "ui://plugin/report.html",
}),
).resolves.toMatchObject({
viewId: "mcp-app-1",
url: "ui://plugin/report.html",
});
expect(loadUrlMock).toHaveBeenLastCalledWith(
expect.stringMatching(/^data:text\/html;charset=utf-8;base64,/),
);
});
it("受控 HTML 命令拒绝普通网页来源,普通导航仍拒绝 ui/data", async () => {
resetMocks();
const host = new ElectronEmbeddedBrowserHost();
const window = createWindow();
await expect(
host.invoke(window as never, "embedded_browser_view_load_html", {
viewId: "mcp-app-1",
html: "<p>bad</p>",
source: "browser",
sourceUri: "ui://plugin/report.html",
}),
).rejects.toThrow("source 必须是 mcpApp");
await expect(
host.invoke(window as never, "embedded_browser_view_navigate", {
viewId: "mcp-app-1",
url: "ui://plugin/report.html",
}),
).rejects.toThrow("只支持 http/https");
await expect(
host.invoke(window as never, "embedded_browser_view_navigate", {
viewId: "mcp-app-1",
url: "data:text/html,unsafe",
}),
).rejects.toThrow("只支持 http/https");
});
it("MCP App 加载后拒绝页面内 http 导航和新窗口", async () => {
resetMocks();
const host = new ElectronEmbeddedBrowserHost();
const window = createWindow();
await host.invoke(window as never, "embedded_browser_view_mount", {
viewId: "mcp-app-1",
});
await host.invoke(window as never, "embedded_browser_view_load_html", {
viewId: "mcp-app-1",
html: '<a href="https://example.com">blocked</a>',
source: "mcpApp",
sourceUri: "ui://plugin/report.html",
});
const preventDefault = vi.fn();
emitWebContentsEvent(
"will-navigate",
{ preventDefault },
"https://example.com/",
);
expect(preventDefault).toHaveBeenCalledOnce();
const openHandler = setWindowOpenHandlerMock.mock.calls.at(-1)?.[0];
expect(openHandler?.({ url: "https://example.com/" })).toEqual({
action: "deny",
});
expect(loadUrlMock).toHaveBeenCalledTimes(1);
});
it("host dispose 不访问已销毁 BrowserWindow", async () => {
resetMocks();
const emitted: Array<{ event: string; payload?: unknown }> = [];
const host = new ElectronEmbeddedBrowserHost((event, payload) => {
@@ -350,11 +450,80 @@ describe("ElectronEmbeddedBrowserHost", () => {
expect(closeMock).toHaveBeenCalled();
expect(emitted).toContainEqual({
event: "embedded-browser-view-destroyed",
payload: { viewId: "browser-1" },
payload: {
viewId: "browser-1",
reason: "host-dispose",
requestedLeaseId: null,
remainingLeaseCount: 0,
},
});
});
it("窗口 renderer 刷新时销毁内嵌浏览器原生视图", async () => {
it("窗口 closed 事件应记录 window-closed 销毁原因", async () => {
resetMocks();
const emitted: Array<{ event: string; payload?: unknown }> = [];
const host = new ElectronEmbeddedBrowserHost((event, payload) => {
emitted.push({ event, payload });
});
const window = createWindow();
await host.invoke(window as never, "embedded_browser_view_mount", {
viewId: "browser-1",
leaseId: "lease-1",
});
window.destroyForTest();
window.emitWindowEventForTest("closed");
expect(closeMock).toHaveBeenCalledOnce();
expect(emitted).toContainEqual({
event: "embedded-browser-view-destroyed",
payload: {
viewId: "browser-1",
reason: "window-closed",
requestedLeaseId: null,
remainingLeaseCount: 1,
},
});
expect(() => host.dispose()).not.toThrow();
expect(closeMock).toHaveBeenCalledOnce();
});
it("陈旧租约释放不得销毁同 viewId 的当前原生视图", async () => {
resetMocks();
const host = new ElectronEmbeddedBrowserHost();
const window = createWindow();
await host.invoke(window as never, "embedded_browser_view_mount", {
viewId: "plugin-surface-1",
leaseId: "lease-old",
bounds: { x: 10, y: 20, width: 300, height: 200 },
});
await host.invoke(window as never, "embedded_browser_view_mount", {
viewId: "plugin-surface-1",
leaseId: "lease-current",
bounds: { x: 10, y: 20, width: 300, height: 200 },
});
removeChildViewMock.mockClear();
closeMock.mockClear();
await host.invoke(window as never, "embedded_browser_view_destroy", {
viewId: "plugin-surface-1",
leaseId: "lease-old",
});
expect(removeChildViewMock).not.toHaveBeenCalled();
expect(closeMock).not.toHaveBeenCalled();
await host.invoke(window as never, "embedded_browser_view_destroy", {
viewId: "plugin-surface-1",
leaseId: "lease-current",
});
expect(removeChildViewMock).toHaveBeenCalledTimes(1);
expect(closeMock).toHaveBeenCalledTimes(1);
});
it("只在主窗口开始完整导航时销毁内嵌浏览器原生视图", async () => {
resetMocks();
const emitted: Array<{ event: string; payload?: unknown }> = [];
const host = new ElectronEmbeddedBrowserHost((event, payload) => {
@@ -371,12 +540,42 @@ describe("ElectronEmbeddedBrowserHost", () => {
removeChildViewMock.mockClear();
closeMock.mockClear();
window.emitWebContentsEventForTest("did-start-loading");
window.emitWebContentsEventForTest(
"did-start-navigation",
{},
"http://127.0.0.1:1420/#settings",
true,
true,
);
window.emitWebContentsEventForTest(
"did-start-navigation",
{},
"https://example.com/frame",
false,
false,
);
expect(removeChildViewMock).not.toHaveBeenCalled();
expect(closeMock).not.toHaveBeenCalled();
window.emitWebContentsEventForTest(
"did-start-navigation",
{},
"http://127.0.0.1:1420/",
false,
true,
);
expect(removeChildViewMock).toHaveBeenCalled();
expect(closeMock).toHaveBeenCalled();
expect(emitted).toContainEqual({
event: "embedded-browser-view-destroyed",
payload: { viewId: "browser-1" },
payload: {
viewId: "browser-1",
reason: "renderer-navigation",
requestedLeaseId: null,
remainingLeaseCount: 0,
},
});
});
+99 -15
View File
@@ -7,6 +7,10 @@ import {
import { installEmbeddedBrowserContextMenu } from "./embeddedBrowserContextMenu";
import { installEmbeddedBrowserDownloadHandling } from "./embeddedBrowserDownloads";
import { installEmbeddedBrowserPermissionHandling } from "./embeddedBrowserPermissions";
import {
buildEmbeddedBrowserHtmlDataUrl,
readEmbeddedBrowserHtmlPayload,
} from "./embeddedBrowserHtml";
import type {
Session as ElectronSession,
WebContents as ElectronWebContents,
@@ -14,11 +18,17 @@ import type {
type HostArgs = Record<string, unknown> | null | undefined;
type HostEventEmitter = (event: string, payload?: unknown) => void;
type EmbeddedBrowserDestroyReason =
| "renderer-cleanup"
| "renderer-navigation"
| "window-closed"
| "host-dispose";
export const EMBEDDED_BROWSER_COMMANDS = [
"embedded_browser_view_mount",
"embedded_browser_view_set_bounds",
"embedded_browser_view_navigate",
"embedded_browser_view_load_html",
"embedded_browser_view_reload",
"embedded_browser_view_stop",
"embedded_browser_view_find_in_page",
@@ -63,8 +73,15 @@ interface EmbeddedBrowserEntry {
view: WebContentsView;
window: BrowserWindow;
closeListener: () => void;
rendererLoadListener: () => void;
rendererNavigationListener: (
event: unknown,
url: string,
isInPlace: boolean,
isMainFrame: boolean,
) => void;
leaseIds: Set<string>;
pendingUrl?: string;
sourceUri?: string;
navigationToken: number;
faviconUrl: string | null;
loadProgress: number;
@@ -96,7 +113,7 @@ export class ElectronEmbeddedBrowserHost {
args?: HostArgs,
): Promise<unknown> {
if (command === "embedded_browser_view_destroy") {
return this.#destroy(args);
return this.#destroy(args, "renderer-cleanup");
}
if (!window || window.isDestroyed()) {
@@ -110,6 +127,8 @@ export class ElectronEmbeddedBrowserHost {
return this.#setBounds(window, args);
case "embedded_browser_view_navigate":
return await this.#navigate(window, args);
case "embedded_browser_view_load_html":
return await this.#loadHtml(window, args);
case "embedded_browser_view_reload":
return this.#reload(window, args);
case "embedded_browser_view_stop":
@@ -131,7 +150,7 @@ export class ElectronEmbeddedBrowserHost {
dispose(): void {
for (const viewId of [...this.#entries.keys()]) {
this.#destroy({ viewId });
this.#destroy({ viewId }, "host-dispose");
}
}
@@ -144,6 +163,10 @@ export class ElectronEmbeddedBrowserHost {
const bounds = readOptionalBounds(args);
const visible = readOptionalBoolean(args, "visible") ?? true;
const entry = this.#ensureEntry(window, viewId);
const leaseId = readString(args, "leaseId");
if (leaseId) {
entry.leaseIds.add(leaseId);
}
if (bounds) {
applyBounds(entry.view, bounds, visible);
@@ -175,6 +198,21 @@ export class ElectronEmbeddedBrowserHost {
return this.#emitState(entry);
}
async #loadHtml(
window: BrowserWindow,
args?: HostArgs,
): Promise<EmbeddedBrowserViewState> {
const entry = this.#ensureEntry(window, readViewId(args));
const payload = readEmbeddedBrowserHtmlPayload(args);
entry.sourceUri = payload.sourceUri;
this.#startNavigation(
entry,
payload.sourceUri,
buildEmbeddedBrowserHtmlDataUrl(payload),
);
return this.#emitState(entry);
}
#reload(window: BrowserWindow, args?: HostArgs): EmbeddedBrowserViewState {
const entry = this.#ensureEntry(window, readViewId(args));
entry.loadProgress = 0.1;
@@ -249,17 +287,31 @@ export class ElectronEmbeddedBrowserHost {
return this.#emitState(entry);
}
#destroy(args?: HostArgs): Record<string, never> {
#destroy(
args: HostArgs,
reason: EmbeddedBrowserDestroyReason,
): Record<string, never> {
const viewId = readViewId(args);
const entry = this.#entries.get(viewId);
if (!entry) {
return {};
}
const leaseId = readString(args, "leaseId");
if (leaseId) {
if (!entry.leaseIds.delete(leaseId) || entry.leaseIds.size > 0) {
return {};
}
}
this.#entries.delete(viewId);
detachEntryFromWindow(entry);
closeEntryView(entry);
this.#emit("embedded-browser-view-destroyed", { viewId });
this.#emit("embedded-browser-view-destroyed", {
viewId,
reason,
requestedLeaseId: leaseId,
remainingLeaseCount: entry.leaseIds.size,
});
return {};
}
@@ -269,8 +321,10 @@ export class ElectronEmbeddedBrowserHost {
if (existing.window !== window) {
detachEntryFromWindow(existing);
existing.window = window;
existing.closeListener = () => this.#destroy({ viewId });
existing.rendererLoadListener = () => this.#destroy({ viewId });
existing.closeListener = () =>
this.#destroy({ viewId }, "window-closed");
existing.rendererNavigationListener =
this.#createRendererNavigationListener(viewId);
attachEntryToWindow(existing, window);
return existing;
}
@@ -297,8 +351,10 @@ export class ElectronEmbeddedBrowserHost {
viewId,
view,
window,
closeListener: () => this.#destroy({ viewId }),
rendererLoadListener: () => this.#destroy({ viewId }),
closeListener: () => this.#destroy({ viewId }, "window-closed"),
rendererNavigationListener:
this.#createRendererNavigationListener(viewId),
leaseIds: new Set<string>(),
navigationToken: 0,
faviconUrl: null,
loadProgress: 1,
@@ -306,6 +362,9 @@ export class ElectronEmbeddedBrowserHost {
findRequestId: null,
};
view.webContents.setWindowOpenHandler(({ url }) => {
if (entry.sourceUri) {
return { action: "deny" };
}
const normalizedUrl = normalizeHttpUrl(url);
if (normalizedUrl) {
this.#startNavigation(entry, normalizedUrl);
@@ -313,6 +372,11 @@ export class ElectronEmbeddedBrowserHost {
}
return { action: "deny" };
});
view.webContents.on("will-navigate", (event, url) => {
if (entry.sourceUri && normalizeHttpUrl(url)) {
event.preventDefault();
}
});
installEmbeddedBrowserContextMenu({
view,
window,
@@ -383,6 +447,16 @@ export class ElectronEmbeddedBrowserHost {
return entry;
}
#createRendererNavigationListener(
viewId: string,
): EmbeddedBrowserEntry["rendererNavigationListener"] {
return (_event, _url, isInPlace, isMainFrame) => {
if (isMainFrame && !isInPlace) {
this.#destroy({ viewId }, "renderer-navigation");
}
};
}
#installSessionHandlers(embeddedSession: ElectronSession): void {
if (this.#sessionHandlersInstalled) {
return;
@@ -416,7 +490,14 @@ export class ElectronEmbeddedBrowserHost {
return null;
}
#startNavigation(entry: EmbeddedBrowserEntry, url: string): void {
#startNavigation(
entry: EmbeddedBrowserEntry,
url: string,
loadUrl = url,
): void {
if (loadUrl === url) {
entry.sourceUri = undefined;
}
entry.pendingUrl = url;
entry.loadProgress = 0.1;
const navigationToken = entry.navigationToken + 1;
@@ -424,7 +505,7 @@ export class ElectronEmbeddedBrowserHost {
let loadPromise: Promise<void>;
try {
loadPromise = entry.view.webContents.loadURL(url);
loadPromise = entry.view.webContents.loadURL(loadUrl);
} catch (error) {
this.#clearPendingNavigation(entry);
this.#emitLoadFailed(
@@ -535,7 +616,7 @@ function readState(entry: EmbeddedBrowserEntry): EmbeddedBrowserViewState {
const webContents = entry.view.webContents;
return {
viewId: entry.viewId,
url: entry.pendingUrl || webContents.getURL(),
url: entry.pendingUrl || entry.sourceUri || webContents.getURL(),
title: webContents.getTitle(),
faviconUrl: entry.faviconUrl,
canGoBack: webContents.navigationHistory.canGoBack(),
@@ -658,7 +739,10 @@ function attachEntryToWindow(
window: BrowserWindow,
): void {
window.on("closed", entry.closeListener);
window.webContents.on("did-start-loading", entry.rendererLoadListener);
window.webContents.on(
"did-start-navigation",
entry.rendererNavigationListener,
);
window.contentView.addChildView(entry.view);
}
@@ -667,8 +751,8 @@ function detachEntryFromWindow(entry: EmbeddedBrowserEntry): void {
if (!entry.window.isDestroyed()) {
entry.window.off("closed", entry.closeListener);
entry.window.webContents.off(
"did-start-loading",
entry.rendererLoadListener,
"did-start-navigation",
entry.rendererNavigationListener,
);
entry.window.contentView.removeChildView(entry.view);
}
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import {
buildEmbeddedBrowserHtmlDataUrl,
EMBEDDED_BROWSER_HTML_MAX_BYTES,
readEmbeddedBrowserHtmlPayload,
} from "./embeddedBrowserHtml";
describe("embeddedBrowserHtml", () => {
it("只接受受控 MCP App HTML 载荷", () => {
expect(
readEmbeddedBrowserHtmlPayload({
html: "<!doctype html><title>Plugin</title>",
source: "mcpApp",
sourceUri: "ui://plugin/report.html",
}),
).toEqual({
csp: {
baseUriDomains: [],
connectDomains: [],
frameDomains: [],
resourceDomains: [],
},
html: "<!doctype html><title>Plugin</title>",
source: "mcpApp",
sourceUri: "ui://plugin/report.html",
});
});
it("拒绝非 MCP App 来源、非 ui URI 与超限 HTML", () => {
expect(() =>
readEmbeddedBrowserHtmlPayload({
html: "<p>bad source</p>",
source: "browser",
sourceUri: "ui://plugin/report.html",
}),
).toThrow("source 必须是 mcpApp");
expect(() =>
readEmbeddedBrowserHtmlPayload({
html: "<p>bad uri</p>",
source: "mcpApp",
sourceUri: "https://example.com/report.html",
}),
).toThrow("sourceUri 必须使用 ui:// 协议");
expect(() =>
readEmbeddedBrowserHtmlPayload({
html: "a".repeat(EMBEDDED_BROWSER_HTML_MAX_BYTES + 1),
source: "mcpApp",
sourceUri: "ui://plugin/report.html",
}),
).toThrow("HTML 超过");
});
it("将 HTML 与受控 CSP 编码为不暴露原文的数据 URL", () => {
const dataUrl = buildEmbeddedBrowserHtmlDataUrl({
csp: {
baseUriDomains: [],
connectDomains: ["https://api.example.com"],
frameDomains: [],
resourceDomains: ["https://cdn.example.com"],
},
html: "<html><head></head><body><h1>报告</h1></body></html>",
});
expect(dataUrl).toMatch(/^data:text\/html;charset=utf-8;base64,/);
expect(dataUrl).not.toContain("报告");
const encoded = dataUrl.slice(dataUrl.indexOf(",") + 1);
const decoded = Buffer.from(encoded, "base64").toString("utf8");
expect(decoded).toContain('http-equiv="Content-Security-Policy"');
expect(decoded).toContain("connect-src https://api.example.com");
expect(decoded).toContain("script-src 'unsafe-inline' https://cdn.example.com");
expect(decoded).toContain("frame-src 'none'");
});
it("拒绝 CSP 指令注入、路径与不受支持协议", () => {
for (const connectDomain of [
"https://api.example.com; script-src *",
"https://api.example.com/path",
"file:///tmp/app",
]) {
expect(() =>
readEmbeddedBrowserHtmlPayload({
csp: { connectDomains: [connectDomain] },
html: "<main>app</main>",
source: "mcpApp",
sourceUri: "ui://plugin/report.html",
}),
).toThrow("包含无效 origin");
}
});
});
+197
View File
@@ -0,0 +1,197 @@
type HostArgs = Record<string, unknown> | null | undefined;
export const EMBEDDED_BROWSER_HTML_MAX_BYTES = 1024 * 1024;
export interface EmbeddedBrowserHtmlPayload {
csp: EmbeddedBrowserMcpAppCsp;
html: string;
source: "mcpApp";
sourceUri: string;
}
export interface EmbeddedBrowserMcpAppCsp {
baseUriDomains: string[];
connectDomains: string[];
frameDomains: string[];
resourceDomains: string[];
}
export function readEmbeddedBrowserHtmlPayload(
args: HostArgs,
): EmbeddedBrowserHtmlPayload {
const record = readRecord(args);
if (record?.source !== "mcpApp") {
throw new Error("embedded browser HTML source 必须是 mcpApp。");
}
const sourceUri = readRequiredString(record, "sourceUri");
if (!isMcpAppResourceUri(sourceUri)) {
throw new Error("MCP App sourceUri 必须使用 ui:// 协议。");
}
const html = readRequiredString(record, "html", false);
const byteLength = Buffer.byteLength(html, "utf8");
if (byteLength > EMBEDDED_BROWSER_HTML_MAX_BYTES) {
throw new Error(
`MCP App HTML 超过 ${EMBEDDED_BROWSER_HTML_MAX_BYTES} 字节上限。`,
);
}
return {
csp: readMcpAppCsp(record?.csp),
html,
source: "mcpApp",
sourceUri,
};
}
export function buildEmbeddedBrowserHtmlDataUrl(
payload: Pick<EmbeddedBrowserHtmlPayload, "csp" | "html">,
): string {
const html = injectMcpAppContentSecurityPolicy(payload.html, payload.csp);
return `data:text/html;charset=utf-8;base64,${Buffer.from(html, "utf8").toString("base64")}`;
}
function readMcpAppCsp(value: unknown): EmbeddedBrowserMcpAppCsp {
const record = readRecord(value);
return {
baseUriDomains: readDomainList(record?.baseUriDomains, "baseUriDomains", [
"http:",
"https:",
]),
connectDomains: readDomainList(record?.connectDomains, "connectDomains", [
"http:",
"https:",
"ws:",
"wss:",
]),
frameDomains: readDomainList(record?.frameDomains, "frameDomains", [
"http:",
"https:",
]),
resourceDomains: readDomainList(
record?.resourceDomains,
"resourceDomains",
["http:", "https:"],
),
};
}
function readDomainList(
value: unknown,
field: string,
protocols: readonly string[],
): string[] {
if (value === undefined) {
return [];
}
if (!Array.isArray(value) || value.length > 32) {
throw new Error(`MCP App CSP ${field} 必须是最多 32 项的数组。`);
}
return Array.from(
new Set(
value.map((entry) => normalizeCspOrigin(entry, field, protocols)),
),
);
}
function normalizeCspOrigin(
value: unknown,
field: string,
protocols: readonly string[],
): string {
if (typeof value !== "string" || value.length > 2048) {
throw new Error(`MCP App CSP ${field} 包含无效 origin。`);
}
const trimmed = value.trim();
const wildcard = /^(https?):\/\/\*\.(.+)$/i.exec(trimmed);
const parseTarget = wildcard ? `${wildcard[1]}://${wildcard[2]}` : trimmed;
try {
const url = new URL(parseTarget);
if (
!protocols.includes(url.protocol) ||
url.username ||
url.password ||
url.pathname !== "/" ||
url.search ||
url.hash ||
!url.hostname
) {
throw new Error("invalid origin");
}
return wildcard
? `${url.protocol}//* .${url.host}`.replace("* .", "*.")
: url.origin;
} catch {
throw new Error(`MCP App CSP ${field} 包含无效 origin。`);
}
}
function injectMcpAppContentSecurityPolicy(
html: string,
csp: EmbeddedBrowserMcpAppCsp,
): string {
const resourceSources = csp.resourceDomains.join(" ");
const policy = [
"default-src 'none'",
`script-src 'unsafe-inline'${appendSources(resourceSources)}`,
`style-src 'unsafe-inline'${appendSources(resourceSources)}`,
`img-src data: blob:${appendSources(resourceSources)}`,
`font-src data:${appendSources(resourceSources)}`,
`media-src data: blob:${appendSources(resourceSources)}`,
`connect-src ${sourcesOrNone(csp.connectDomains)}`,
`frame-src ${sourcesOrNone(csp.frameDomains)}`,
`base-uri ${sourcesOrNone(csp.baseUriDomains)}`,
"object-src 'none'",
"form-action 'none'",
].join("; ");
const tag = `<meta http-equiv="Content-Security-Policy" content="${escapeHtmlAttribute(policy)}">`;
const headMatch = /<head(?:\s[^>]*)?>/i.exec(html);
if (headMatch?.index !== undefined) {
const offset = headMatch.index + headMatch[0].length;
return `${html.slice(0, offset)}${tag}${html.slice(offset)}`;
}
return `${tag}${html}`;
}
function appendSources(value: string): string {
return value ? ` ${value}` : "";
}
function sourcesOrNone(values: readonly string[]): string {
return values.length > 0 ? values.join(" ") : "'none'";
}
function escapeHtmlAttribute(value: string): string {
return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;");
}
function isMcpAppResourceUri(value: string): boolean {
try {
return new URL(value).protocol === "ui:";
} catch {
return false;
}
}
function readRequiredString(
record: Record<string, unknown> | null,
key: string,
trim = true,
): string {
const value = record?.[key];
if (typeof value !== "string") {
throw new Error(`embedded browser ${key} 不能为空。`);
}
const normalized = trim ? value.trim() : value;
if (!normalized.trim()) {
throw new Error(`embedded browser ${key} 不能为空。`);
}
return normalized;
}
function readRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
+1
View File
@@ -135,6 +135,7 @@ describe("electron/ipcChannels", () => {
true,
);
expect(isElectronHostCommand("embedded_browser_view_navigate")).toBe(true);
expect(isElectronHostCommand("embedded_browser_view_load_html")).toBe(true);
expect(isElectronHostCommand("embedded_browser_view_reload")).toBe(true);
expect(isElectronHostCommand("embedded_browser_view_stop")).toBe(true);
expect(isElectronHostCommand("embedded_browser_view_find_in_page")).toBe(
+1
View File
@@ -18,6 +18,7 @@ export const ELECTRON_HOST_COMMANDS = [
"embedded_browser_view_mount",
"embedded_browser_view_set_bounds",
"embedded_browser_view_navigate",
"embedded_browser_view_load_html",
"embedded_browser_view_reload",
"embedded_browser_view_stop",
"embedded_browser_view_find_in_page",
+1
View File
@@ -240,6 +240,7 @@ export default {
: "lime-rs/icons/icon.icns",
extraResource: [
"dist-electron/desktop-assets",
"dist-electron/plugins",
"dist-electron/app-server.release.json",
"dist-electron/app-server",
],
+41
View File
@@ -891,6 +891,47 @@ Architecture impact: major; this changes the Renderer live/read/resume Item stat
Plugin UI/worker 只通过 typed client 和 App Server method 进入 runtime;不得复制 turn start、cancel、tool dispatch 或 evidence 链。Skill 是产品与领域工作流单元,MCP 是标准化 tool/resource/prompt 接入;二者均由 App Server 和各自 runtime owner 注入当前 turn,而不是由 Renderer 直接执行。
Plugin v2 的 catalog、installed state、enabled state、package identity 和安装事务由 App Server current domain 唯一持有。Renderer App Center 只能通过 `src/lib/api/pluginCatalog.ts` 调用 typed `plugin/list``plugin/read``plugin/install``plugin/uninstall``plugin/installed``plugin/enabled/set`Electron 只转发 `app_server_handle_json_lines` 和提供原生目录选择,不扫描 `.codex-plugin/plugin.json`、不合并 marketplace,也不保存第二份 installed state。
```text
PluginCatalogPage
-> Renderer pluginCatalog gateway
-> typed AppServerClient
-> Electron preload / app_server_handle_json_lines
-> App Server Plugin processor
-> RuntimeCore PluginDataSource
-> plugin_catalog
catalog discovery
package validation + sha256 identity
staging + atomic installed record
-> <AgentRoot>/plugins/v2/{packages,installed,staging,marketplaces}
```
包合同只接受 Codex-compatible `.codex-plugin/plugin.json`;旧 `schemaVersion``contributions.runtime/workbench`、绝对或父级资源路径、symlink、超预算 package 必须 fail closed。安装 identity 至少包含 `pluginId + marketplaceId + version + contentDigest`。同 identity 同 digest 的重复安装幂等;同 identity 不同 digest 拒绝;更新必须先提交新版本和 installed record,失败时保留上一可用版本。`sourceUri` 只用于受控本地 catalog/install 响应,不进入 Thread 历史、日志或跨设备 metadata。
installed+enabled Plugin 由同一 App Server store 生成 `plugin-activation/v2``plugin-runtime-capabilities/v2`。activation snapshot 冻结 `pluginId + version + contentDigest + marketplaceId + packageSourceUri`,其 Skills 进入现有 skill snapshotRenderer 不得构造或注入 activation。Claw 的已安装候选只从 typed `plugin/installed` 投影,选择后把显示文本 `@DisplayName` 与结构化 `{ type: "mention", name, path: "plugin://<pluginId>" }` 同时送入 current turn contract。App Server 使用同一回合 activation snapshot 校验 mention:无显式 mention 时保留全部 enabled Plugin 供描述发现;有 `plugin://` 时只把解析成功的 Plugin activation 装配到该回合,未知、禁用或 identity 漂移必须 fail closed,不能查询旧 renderer registry 或旧 installed state 兜底。
```text
App Server plugin v2 installed store
-> enabled activation snapshot
-> RuntimeCore session config / Skill snapshot
Claw @ picker
-> Renderer pluginCatalog gateway -> plugin/installed
-> stable plugin://<pluginId> UserInput::Mention
-> Electron IPC / app_server_handle_json_lines
-> App Server mention selection
-> selected activation -> Turn metadata / runtime context
```
Plugin MCP 声明由唯一 package manifest `.codex-plugin/plugin.json` 选择:可以使用 manifest 内联 object、package-relative 配置路径,或包根默认 `.mcp.json``.mcp.json` 只是 MCP server 配置,不是第二份 Plugin manifest。installed+enabled Plugin 生成带稳定 `plugin__<plugin-id>__<server-id>` runtime identity 的 server spec,并进入既有 `McpThreadRuntime -> McpClientManager -> tool-runtime` lifecycledisabled Plugin 不装配,非法 sibling 与越界 `cwd` fail closed。MCP App UI 从 canonical tool item 的 `resourceUri``mcpResource/read` 进入现有 Right Surface/WebContentsView,并以 canonical identity 在 Renderer reload 后恢复,不启动 Plugin worker 或私有 UI runtime。
当前仍未完成独立 Plugin Apps(非 MCP App)与 Hooks activation、Plugin identity 到完整 Item/tool trace 的全量历史投影、跨进程 cold restore,以及 Browser/file/structured-result 的 Plugin 专项 Gate B;这些缺口不得通过旧 renderer registry、plugin worker 或独立 UI runtime fallback 填补。
2026-08-05 的 macOS controlled fixture 已证明 `plugin/install -> enabled activation -> runtime MCP tool -> elicitation -> provider final -> canonical item -> MCP App Right Surface -> Renderer reload restore`,且 preload/IPC 与 `app_server_handle_json_lines` 可见、legacy MCP command / production mock fallback / Plugin worker hit 均为 0。证据位于 `.lime/qc/gui-evidence/plugin-v2-current-electron-fixture/`;该证据不扩张为 App Center 安装点击、Claw `@` picker 点击、卸载历史或 Windows release gate 已通过。
Architecture impact: major; this adds the Plugin v2 App Server domain, moves the production App Center and Claw mention source to typed JSON-RPC catalog/activation state, and preserves Electron as transport host. Architecture diagram updated: this section. Responsible developer confirmation: pending.
MCP server 的执行环境身份只来自 `McpServerConfig.environment_id`,由 `lime-mcp::McpEnvironmentRegistry`
在 transport 启动前解析。当前 registry 只注册 `local`;未知显式身份必须 fail closed,禁止把
`remote` 或其它配置值降级成本机 stdio/HTTP 执行,也禁止从 `cwd` 猜测环境。远程 executor/backend
+2
View File
@@ -36,6 +36,7 @@
- Lime v1.114.0 发布执行计划:`internal/exec-plans/release-v1.114.0-plan.md`
- Lime v1.115.0 发布执行计划:`internal/exec-plans/release-v1.115.0-plan.md`
- Lime v1.116.0 发布执行计划:`internal/exec-plans/release-v1.116.0-plan.md`
- Lime v1.121.0 发布执行计划:`internal/exec-plans/release-v1.121.0-plan.md`
- Codex App GUI 对齐执行计划:`internal/exec-plans/codex-app-gui-alignment-plan.md`
- Codex 对话兼容重构:`internal/exec-plans/codex-conversation-compat-refactor-plan.md`
- Codex 对齐 v1 并行协调:`internal/exec-plans/codex-alignment-v1-coordination-plan.md`
@@ -64,6 +65,7 @@
- `@` 命令本地执行纠偏计划:`internal/exec-plans/at-command-local-execution-alignment-plan.md`
- Agent QC 运营级测试体系执行计划:`internal/exec-plans/agent-qc-ops-testing-plan.md`
- Agent App v2 独立安装与 Runtime 底座拆分执行计划:`internal/exec-plans/agentapp-v2-standalone-runtime.md`
- Plugin v2 current 实施与 Gate B 计划:`internal/exec-plans/plugin-v2-current-plan.md`
- AI 图层化设计实现计划:`internal/exec-plans/ai-layered-design-implementation-plan.md`
- 图片能力 feature-flag / extension-tool 执行计划:`internal/exec-plans/image-capability-feature-flag-extension-tool-plan.md`
- 图片能力 feature-flag / extension-tool 进度:`internal/exec-plans/image-capability-feature-flag-extension-tool-progress.md`
@@ -447,3 +447,23 @@ Electron Desktop Host
- current adapter 改用仓库已锁定的 Node `smol-toml` 结构化解析器,移除 Python subprocess 和版本假设;未引入 ad-hoc 行解析,也未改变 DeepSWE task schema、workspace 隔离、live authorization 或 verifier ownership。
- 修复后验证:`npx vitest run scripts/harness/deepswe-adapter.test.mjs scripts/harness/deepswe-coding-slice.test.mjs``26/26``npm run harness:deepswe:preflight` 为 Release 20 `61/61`adapter ESLint、`npm run governance:scripts``npm run test:contracts` 全部通过。
- 分类:Node TOML metadata parser 和 adapter fail-closed/verifier evidence 为 `current / closed`Python `tomllib` 运行时假设为 `dead / deleted / forbidden-to-restore`。完整 DeepSWE 仍是 `diagnostic_true_runs_blocked`:Agnes 选题固定预算内无 non-empty candidatePier editable package 已失效且本机无 Docker/Podman/nerdctl/Colima,不能生成 `reward.json``ctrf.json` 或 score;第二期整体完成度保持 `80%`
## 41. 2026-08-04 Benchmark 新一轮 Gate B 验收
- 目标与写集:复跑 `internal/roadmap/benchmark` 的 current fixture、恢复、cancel/continue、approval/resume、unknown Item、AgentControl cold-restart、`home-hotpath``SOAK-01`,只新增 `.gstack/benchmark-reports/2026-08-04-benchmark.{md,json}` 与本轮失败证据;未修改业务源码。
- 真实 Electron 性能观测:通过 `http://127.0.0.1:9223` 确认 Lime/Electron 42.3.3、`window.__LIME_ELECTRON__=true``electronAPI.invoke`reload 的 TTFB/FCP/DOM Interactive/Full Load 为 `49/412/316/1424ms`250 个请求,transfer `505873` bytesreload console/page error 均为 0。
- Gate B 通过:history/cache `31/31`、streaming completion `32/32`、fixture guard `99/99`session `thread/start/read/list/resume`、cancel/continue、approval/resume、unknown Item 均为 current Electron/App Server/read model/GUI 闭环;AgentControl cold-restart 39 条 assertion 全绿,证据分别见 `.lime/qc/gui-evidence/benchmark-20260804/` 和对应 current fixture 目录。
- `home-hotpath` 保持失败:250ms budget 下 `homeInputToSendDispatchMs` 两次为 `287ms``274ms`7 月成功基线为 `100ms``114ms`provider wait `90ms`、renderer 首次 delta apply `2ms`turn/read model/GUI/Electron IPC 成功,invoke/page/console error 为 0。归因仍是 Renderer 首发 dispatch 性能回归,不是 provider、IPC、Runtime 或 terminal failure。
- `SOAK-01` 未通过:10 轮 / 2 次 cold restart 在首轮失败,唯一失败断言为 `waitAgentReturnedTerminalResult`;其他工具、turn terminal 和 `thread/read` 完成。单轮 AgentControl cold-restart 后续通过,不能替代完整 soak receipt。
- 最后一次 cold-restart recheck 在 `launch-electron` 失败:`canvasImageInsertHistory-BQ3-T3zp.js` 不提供导出 `c`,bridge 未就绪;该证据归类为构建产物/启动环境不一致,需清理并一致构建后复测,当前不直接归因 runtime owner。
- Gate B 结论:`partial evidence / failed acceptance`。核心链路已证明,但 `home-hotpath``SOAK-01` 和 cold-restart recheck 仍是 release evidence blocker;下一刀为 dispatch 性能定位、构建产物一致性复测和 SOAK-01 10x2 完整重跑。
## 42. 2026-08-04 Benchmark Gate B 修复后复验
- 目标与写集:在第 41 节失败现场上继续复验 `home-hotpath`、AgentControl cold-restart 和 `SOAK-01`;写集为 SOAK 观察器、观察器单测、已有 Renderer dispatch/构建一致性修复及 `.gstack/benchmark-reports/2026-08-04-benchmark.{md,json}`,未修改 Rust RuntimeCore 生产逻辑,未放宽投影 repair 的 fail-closed 断言。
- SOAK 观察器根因:v2 `thread/list``Thread` 主键字段是 `id`,不是 `threadId`;旧观察器因此把真实 canonical thread 误判为未列出。请求同时从旧 `includeArchived` 对齐到 v2 `archived: false`,断言现在要求 `id + sessionId` 精确匹配,并由 `tool-execution-soak-evidence.test.mjs` 的 10 条单测覆盖。
- `home-hotpath` 修复后真实 Electron Gate B 通过:`homeInputToSendDispatchMs=37ms`(预算 250ms),pending preview paint `63ms`first text paint `543ms`provider wait `90ms`renderer 首次 delta apply `1ms`first delta 到 paint `23ms`Electron/preload/IPC/App Server/read model/GUI 闭环成功,console/page error 为 0。证据:`.lime/qc/gui-evidence/benchmark-20260804/benchmark-gateb-home-hotpath-after-observer-fix-20260804-summary.json`
- 一致 renderer 重建后 AgentControl cold-restart 独立复验通过,旧 `canvasImageInsertHistory` export mismatch 未复现;证据:`.lime/qc/gui-evidence/benchmark-20260804/benchmark-gateb-agent-control-cold-restart-after-renderer-rebuild-20260804.json`
- `SOAK-01` 10 轮 / 2 次 cold restart 通过:`allRoundsPassed``roundSessionsIsolated``readModelsStableAcrossColdRestarts``everyPreviousProcessTreeExited``finalProcessTreeExited` 全为 `true`;总 RSS `561,312 -> 442,768 KiB`delta `-118,544 KiB`),App Server RSS delta `+8,976 KiB`10 轮 duration `2,820-5,371ms`。证据:`.lime/qc/soak-01/agent-control-soak-final.json`
- 验证:SOAK evidence 单测 `10/10`、单轮 cold-restart、10x2 SOAK、home-hotpath 及一致构建后的 cold-restart 全部通过;未执行 commit/push/reset/分支操作。
- 分类与结论:v2 `thread/list` 字段归一化和 SOAK 观察器为 `current / closed`;旧 `threadId` 观察字段与 `includeArchived` 请求为 `dead / deleted / forbidden-to-restore`;历史失败 JSON/截图保留为诊断 evidence。当前本轮 Gate B 为 `passed`,但本地 controlled fixture 不能替代 live provider、Windows RC 或正式签名/notarization 证据。
@@ -0,0 +1,69 @@
# Lime v1.121.0 发布执行计划
状态:completed
日期:2026-08-05
目标版本:`1.121.0`
目标 tag`v1.121.0`
## 主目标
`v1.120.1` 为基线,将当前工作树中的 Plugin v2 catalog/安装/启停、Agent runtime activation、Claw mention、MCP App Right Surface、受控 Browser HTML 宿主、Skills watcher 修复及其协议、GUI、测试和文档作为同一 release candidate,完成版本事实源、双语 release notes、发布门禁、release commit、tag、`main`/tag 推送与远端复核。
## 当前阶段与下一刀
- 当前阶段:版本事实源、双语 release notes、发布门禁、架构图确认及 Git 发布收口均已完成。
- 下一刀:无;后续由 `v1.121.0` 发布后的线上观察与下一版本执行计划承接。
## Release Candidate
- 基线:`v1.120.1`,本地 `main``origin/main` 均为 `bba332c65`
- 初始盘点:145 个 tracked diff 文件、48 个未跟踪文件;tracked diff 为 7,838 行新增、537 行删除。
- 门禁期间并行候选补入 canonical store 快照持久化、SQLite busy timeout 与回归测试;发版文件及闭环修复纳入后为 154 个 tracked diff 文件、49 个未跟踪文件,其中 `.gitignore` 仍排除。
- `release metadata``package.json``packages/lime-cli-npm/package.json``lime-rs/Cargo.toml``lime-rs/Cargo.lock``RELEASE_NOTES.md``RELEASE_NOTES.en.md`、本计划及执行计划索引。
- `candidate changes`:除 `.gitignore` 外的当前工作树产品、协议、schema、生成客户端、Rust runtime、Electron host/fixture、GUI、五语资源、测试、bundled Plugin 资产与文档改动。
- `excluded changes``.gitignore` 中本地 `.gstack/` 忽略项;该改动已在 `v1.120.1` 发布计划中明确排除,本次继续保留在未提交工作树。
- 并行避让:除 release metadata 与本计划外,其余候选文件只读验证;门禁暴露 blocker 时只做最小可验证修复并回写本计划。
## 写集与退出条件
- 版本事实源与双语 release notes 统一为 `1.121.0`notes 只保留当前版本单页。
- 必跑 `npm run verify:app-version``npm run typecheck``npm run test:contracts`、受影响 Rust 验证、`npm run smoke:plugin-v2-current-electron-fixture``npm run verify:gui-smoke``git diff --check`
- 候选已修改 `internal/aiprompts/architecture.md` 并补充 Plugin v2 current 图。直接发布 `main` 无 PR 描述,责任开发者必须在 Git 高风险操作确认中同时确认架构图,未确认不得进入 release evidence。
- Git 写操作(stage、commit、tag、push)须在门禁完成后按危险操作格式取得一次明确确认。
- 完成 release commit、`v1.121.0` tag、`main` 与 tag 推送后,复核本地与远端引用;`.gitignore` 应作为唯一未提交排除项保留。
## 已知非本版阻断项
- Windows 安装到卸载证据、360px 级五语最长文案截图矩阵、repo/personal marketplace 的完整用户路径、更新/授权/管理员策略与安装事务崩溃恢复未验收。
- 通用 Plugin Apps/Hooks、Browser intent、文件/结构化结果与完整 Plugin identity 追踪仍由 `plugin-v2-current-plan.md` 后续阶段承接。
- 本版只完成上述已实现的 macOS current 候选发布,不将 Plugin v2 整体路线图标记为完成。
## 验证记录
- `npm run verify:app-version`:通过,根应用、CLI npm 包、Rust workspace/lock 与 Electron/App Server 版本统一为 `1.121.0`
- `npm run typecheck`:通过,Renderer 与 Node TypeScript 发布硬门禁无错误。
- `npm run test:contracts`:通过;862 个协议类型生成失败 0/漂移 0App Server client 292 checks,命令、Harness、多模态、脚本、Forge 发布流程与文档边界通过。
- `npm run test:rust:changed -- --changed=origin/main`:版本/lock 触发后自动扩大为 `cargo test --lib --workspace`;首轮暴露 reasoning 指数重复和两个 Codex 导入性能失败,修复持久化二次 merge 及重复 delta 语义后重跑全 workspace 通过。
- Rust 定向回归:`thread-store` 重复 reasoning delta/final 快照与空 completion 保留快照 1/1App Server canonical reasoning 线性持久化、空 completion 保留物化内容及持久化恢复 3/3;Codex 1,200 命令同步导入与 40 turn 后台导入 2/2,分别约 4.0s 与 9.6s。
- `npm run smoke:plugin-v2-current-electron-fixture`:通过,Gate B/runtime 覆盖 App Center 安装/启停、Claw mention、MCP tool/resource、Right Surface、reload、cold restart、卸载及历史恢复;summary=`.lime/qc/gui-evidence/plugin-v2-current-electron-fixture/plugin-v2-current-electron-fixture-summary.json`
- `npm run verify:gui-smoke`:通过,真实 Electron/preload/IPC/`app_server_handle_json_lines`/App Server current 链路、版本 `1.121.0`、reload、Workbench 与 Settings 正常;summary=`.lime/qc/project-gates/standalone-shell-01-20260805091101-56033/shell-01-electron-smoke/summary.json`
- `npm run i18n:check:json`:通过,5 个 locale、13 个 namespace、10,063 个源键,缺失/多余均为 0。
- `cargo fmt --manifest-path "lime-rs/Cargo.toml" --all -- --check`:通过。
- `git diff --check`:通过。
- GUI 构建只输出已知非阻断告警:`oem-runtime-config.js` 非 module script、Browserslist 数据过期与 Electron `console-message` API 废弃提示;本次未更新核心依赖。
## 架构确认
- `internal/aiprompts/architecture.md` 已记录 Plugin v2 catalog、activation snapshot、MCP runtime/Right Surface 和历史恢复主链图。
- 责任开发者确认:confirmedPlugin v2 current 图与本次发布候选主链一致。
## 分类
- `current`Plugin v2 App Server catalog/install/enabled owner、typed JSON-RPC gateway、activation snapshot、MCP runtime/resource 与 Right Surface current 投影。
- `compat`:无新增;保留的旧 surface 只能委托 current owner。
- `deprecated`Plugin v1 manifest/parser/registry/worker/UI runtime 继续按 `plugin-v2-current-plan.md` 迁出,本次不恢复调用。
- `dead / deleted / forbidden-to-restore`:无本轮新增删除;生产 mock fallback、Plugin worker 第二 runtime 与 renderer 旧 registry 不得回流 current 主链。
## 完成度
- 当前发版完成度:100%。候选范围、发版文件、标准门禁、架构图确认、release commit、tag、`main`/tag 推送与远端复核均已完成。
+4 -4
View File
@@ -8,10 +8,10 @@
- `internal/roadmap/rightsurface/README.md`
- `internal/roadmap/plugin/README.md`
- `internal/roadmap/plugin/prd.md`
- `internal/roadmap/plugin/architecture.md`
- `internal/roadmap/plugin/interface-contracts.md`
- `internal/roadmap/plugin/technical-baseline.md`
- `internal/roadmap/plugin/v2/01-product-contract.md`
- `internal/roadmap/plugin/v2/03-architecture-and-command-contracts.md`
- `internal/roadmap/plugin/v2/04-app-center-and-claw-surfaces.md`
- `internal/roadmap/plugin/v2/07-verification-contract.md`
- `internal/roadmap/agentworkbench/README.md`
- `internal/aiprompts/commands.md`
- `internal/aiprompts/quality-workflow.md`
+40 -73
View File
@@ -1,91 +1,58 @@
# Lime 插件路线图
# Lime Plugin 路线图
更新时间:2026-06-27
状态:Skeleton Implemented / Productization In Progress
更新时间:2026-08-04
## 1. 定位
状态:`Plugin v2 proposed / implementation-ready`
Lime 的一级产品概念统一为 **插件**。插件负责安装、授权、发布、启用与分发;`插件工作区能力` 只作为插件内部的一种能力形态,表示“带独立 UI 的工作台能力”。旧 `Plugin / 工作台应用` 命名只允许作为迁移输入或历史证据,不再作为插件中心的产品根对象。
## 当前事实源
Plugin 当前路线图统一进入 [v2/README.md](./v2/README.md)。v2 直接采用 Codex-compatible `.codex-plugin/plugin.json`、marketplace、Skills、MCP servers/apps 与 Hooks 模型,并固定以下生产主链:
```text
插件 = 分发与授权根对象
插件工作区能力 = 插件内部 UI 子能力
Right Surface = Host 管理的产物渲染工作区
Electron Desktop Host
-> App Server JSON-RPC
-> RuntimeCore / MCP / Skills / Hooks
-> Thread / Turn / Item projection
-> App Center / Claw / Right Surface
```
这条路线参考上游插件与市场模型:插件使用 `<plugin>@<marketplace>` 稳定 IDmarketplace 下发 manifest 摘要、安装策略和认证策略,用户通过显式选择或 `@` 调用,而不是靠语义猜测
App Center 参考 Codex 的信息架构和状态语义,但继续使用 Lime 当前主题。Claw 通过 `@plugin` 调用能力,并在唯一 Right Surface 中承载 MCP/App UI、Browser、结构化结果和文件预览
服务端 marketplace 事实源在 LimeCore
## 阅读顺序
```text
LimeCore 外仓 plugin roadmap / control-plane 文档
GET /api/v1/public/tenants/{tenantId}/client/plugins/marketplace
```
1. [研究结论](./v2/00-research-findings.md)
2. [产品合同](./v2/01-product-contract.md)
3. [包、市场与安装](./v2/02-package-marketplace-installation.md)
4. [架构与命令合同](./v2/03-architecture-and-command-contracts.md)
5. [App Center 与 Claw Surface](./v2/04-app-center-and-claw-surfaces.md)
6. [迁移与清理账本](./v2/05-migration-and-cleanup.md)
7. [实施计划](./v2/06-implementation-plan.md)
8. [Gate B 验收合同](./v2/07-verification-contract.md)
Lime Desktop 只负责本地安装态、显式激活、Right Surface 渲染和 App Server prompt context;不在 Lime App Server 内新增 marketplace 服务端。
## Legacy 声明
## 2. 文档索引
本目录根部旧 PRD、架构、接口、原型、证据和实施跟踪文件保留为 `historical reference`,用于回看产品洞察、交互方案、历史决策和既有证据。它们不再是 current 设计、实现或验收事实源,也不得独立继续演进。
| 文档 | 用途 |
| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
| [`prd.md`](./prd.md) | 插件产品定义、背景、用户路径、需求、里程碑和验收。 |
| [`architecture.md`](./architecture.md) | 插件/工作区能力/Renderer/Claw/Right Surface 的分层架构。 |
| [`technical-baseline.md`](./technical-baseline.md) | 插件运行的宿主基线、承载方式、current / deprecated / dead 分类。 |
| [`interface-contracts.md`](./interface-contracts.md) | 插件 manifest、激活上下文、renderer contract、surface action contract。 |
| [`implementation-plan.md`](./implementation-plan.md) | 插件中心、composer 激活、renderer host、迁移收口的实施顺序。 |
| [`../../tech/plugin/README.md`](../../tech/plugin/README.md) | Lime Plugin Package v1 技术标准:`plugin.json`、runtime/workbench、skills、worker 和验证口径。 |
| [`history-product-workspace.md`](./history-product-workspace.md) | 历史对话、插件上下文和产物 tab 的恢复规则。 |
| [`user-operations-guide.md`](./user-operations-guide.md) | 插件中心消费、授权、本地安装态、显式激活、Right Surface 和运营排查指南。 |
| [`e2e-evidence.md`](./e2e-evidence.md) | 第二轮跨仓端到端证据包,串联 LimeCore 发布 / 审计与 Lime Desktop GUI fixture。 |
| [`evidence/plugin-productization-e2e-summary.json`](./evidence/plugin-productization-e2e-summary.json) | 本轮桌面 GUI fixture 的版本化精简 summary。 |
| [`prototype.html`](./prototype.html) | 可直接打开的静态 HTML 原型。 |
| [`prototype.md`](./prototype.md) | 插件中心、插件详情、激活 strip、右侧 dock 的低保真原型。 |
`lime.plugin.package.v1`、根 `plugin.json``contributions.runtime/workbench`、插件专用 worker 和 renderer registry 仍处于 `deprecated`,不得新增调用或能力;只有 current 主链迁移和 Gate B 验收完成后才能删除。文档保留与旧实现退役是两个独立治理维度,详见 [迁移与清理账本](./v2/05-migration-and-cleanup.md)。
## 2.1 图表索引
## 历史参考导航
| 图表 | 位置 | 用途 |
| ---------------------- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| 跨仓职责图 | [`architecture.md`](./architecture.md#31-跨仓职责图) | 固定 LimeCore marketplace、Lime Desktop、本地 registry、App Server prompt context 与 Right Surface 的职责边界。 |
| 分层架构图 | [`architecture.md`](./architecture.md#4-分层架构) | 固定 Plugin Contract、Runtime、Session Plugin Workspace、Right Surface 的分层。 |
| 显式激活时序图 | [`architecture.md`](./architecture.md#显式激活时序图) | 描述 `@plugin` 从 composer 到 App Server prompt context 的时序。 |
| Marketplace 消费流程图 | [`architecture.md`](./architecture.md#marketplace-消费流程图) | 描述客户端拉取 LimeCore marketplace、合并本地安装态和构造 activation metadata。 |
| 历史恢复拓扑 | [`architecture.md`](./architecture.md#8-历史恢复拓扑) | 描述历史会话、plugin workspace 和 Right Surface 恢复关系。 |
| Surface Action 回流图 | [`architecture.md`](./architecture.md#9-surface-action-回流) | 固定右侧 action 必须回流 runtime,不直连 provider 或文件系统。 |
| 开发切片总览 | [`implementation-plan.md`](./implementation-plan.md#2-开发切片总览) | 跟踪从文档 contract 到内容工厂 dogfood 的实施顺序。 |
| 客户端消费流程图 | [`user-operations-guide.md`](./user-operations-guide.md#3-端到端消费流程) | 描述插件中心拉取、合并本地 registry、安装、上报和显式激活。 |
| 显式激活时序图 | [`user-operations-guide.md`](./user-operations-guide.md#4-用户路径) | 描述 `@插件` 从 UI 到 App Server current 运行链的时序。 |
| 安装态与审计流程图 | [`user-operations-guide.md`](./user-operations-guide.md#7-安装态与审计) | 描述本地 installed registry 与 LimeCore 审计之间的边界。 |
| 跨仓证据流程图 | [`e2e-evidence.md`](./e2e-evidence.md#2-跨仓证据流程图) | 描述平台发布、客户端消费、安装态报告、显式激活、Right Surface 和审计证据。 |
### 产品与设计
## 3. 当前决策
- [旧版 PRD](./prd.md)
- [旧版架构](./architecture.md)
- [旧版接口合同](./interface-contracts.md)
- [旧版技术基线](./technical-baseline.md)
- [旧版交互原型说明](./prototype.md)与[静态原型](./prototype.html)
- [历史工作区与恢复设计](./history-product-workspace.md)
- [旧版用户操作指南](./user-operations-guide.md)
| 决策 | 口径 |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 根对象 | 插件是用户侧可安装、可授权、可发布的根对象。 |
| Marketplace 服务端 | `current` 在 LimeCore control-plane,不在 Lime App Server。 |
| 插件工作区能力 | 作为插件内独立 UI 能力;旧 Plugin / 工作台应用只允许作为迁移输入,不再作为插件 marketplace 的设计模板。 |
| 右侧渲染 | Right Surface 继续作为 Host 的唯一物理右栏,插件只提供数据模型、视图与 action。 |
| 激活方式 | 不再在每次发送消息时全量读取插件列表做语义猜测;激活必须显式。 |
| Renderer 输出合同 | manifest / marketplace summary 可以声明 `outputArtifactKind``paneKind` 和 action,但 runtime 当前只接管内容工厂 workspace patch,不开放任意插件执行。 |
| 内容工厂 | 内容工厂应作为插件重建,而不是复用旧 `旧内容工作台` 代码。 |
| 现有路线上下文 | 现有 `rightsurface` 负责统一右侧 dockplugin 路线只定义该 dock 如何承载插件产物。 |
| 旧插件中心命令 | 历史插件中心命令族继续按 `dead` 处理,不恢复为生产入口。 |
| 旧 Plugin 目录 | `client/plugins` 和本地 Plugin manifest 投影只允许作为 compat / migration 输入;插件中心 current 数据源是 `client/plugins/marketplace` 与本地插件安装态。 |
| `@` 命令边界 | 平台 `@` 原子命令仍以 `SkillCatalog.entries.kind=command` 为事实源;插件只可贡献显式 activation command entry,并通过 `agentSession/turn/start` metadata 进入 current 主链。 |
### 历史跟踪与证据
## 4. 与现有路线图关系
- [旧版实施计划](./implementation-plan.md)
- [旧版 E2E 证据](./e2e-evidence.md)
- [历史 E2E 摘要](./evidence/plugin-productization-e2e-summary.json)
- [旧发布中心 PRD](./deverlop/plugin-publish-center-prd.md)
- [旧发布服务端计划](./deverlop/plugin-publish-limecore-server-plan.md)
| 路线图 | 关系 |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------- |
| [`internal/roadmap/rightsurface/README.md`](../rightsurface/README.md) | 定义唯一右侧 dock、多 tab 和 pane 状态机;plugin 路线复用这层宿主。 |
| [`internal/roadmap/workbench/v4/README.md`](../workbench/v4/README.md) | 定义插件工作区能力在插件体系里的位置,以及内容工厂的工作台形态。 |
| [`internal/roadmap/workbench/v3/README.md`](../workbench/v3/README.md) | 作为历史参考,帮助理解从 Workbench Profile 过渡到插件分层的原因。 |
## 5. 开发者阅读顺序
1. 先读 [`prd.md`](./prd.md)。
2. 再读 [`architecture.md`](./architecture.md)。
3. 然后读 [`technical-baseline.md`](./technical-baseline.md) 了解宿主边界。
4. 对照 [`interface-contracts.md`](./interface-contracts.md) 固定 contract。
5. 落包结构、skills、worker 或 workbench 时,回到 [`../../tech/plugin/README.md`](../../tech/plugin/README.md) 作为技术事实源。
6. 按 [`implementation-plan.md`](./implementation-plan.md) 分阶段落地。
从历史文档复用的有效决策必须先写入 v2 对应合同并通过 current owner 审核,不能让实现直接同时依赖 v1 与 v2。
@@ -0,0 +1,238 @@
# Plugin v2 研究结论
状态:`research-complete`
取证日期:2026-08-04
## 研究问题
本次研究回答四个问题:
1. Codex 现在如何定义、发现、安装和运行插件?
2. ChatGPT/Codex Desktop 的真实安装包如何组织 bundled plugins
3. Lime 当前实现与 Codex 的关键差异是什么?
4. 哪些差异属于 Lime 产品特性,哪些只是应删除的历史自定义协议?
## 官方文档结论
本次使用当前 Codex Manual 并检索以下官方页面:
- [Build plugins](https://learn.chatgpt.com/docs/build-plugins)
- [Package your plugin](https://developers.openai.com/plugins/build/plugins)
- [Plugin architecture](https://developers.openai.com/plugins/concepts/plugins)
- [Build an MCP server](https://developers.openai.com/plugins/build/mcp-server)
- [Connect and test your plugin](https://developers.openai.com/plugins/deploy/connect-chatgpt)
- [Plugins](https://learn.chatgpt.com/docs/plugins)
- [Skills and plugins](https://learn.chatgpt.com/docs/skills-and-plugins)
- [Plugin controls](https://learn.chatgpt.com/docs/enterprise/apps-and-connectors)
文档给出的稳定结论:
- Plugin 是可安装的能力包,可以包含 Skills、MCP server/apps、Hooks、assets 与展示元数据。
- Skill 适合可复用指令与资源;MCP 适合实时数据、鉴权、受控动作或远端执行;UI 只在结构化交互确有价值时加入 MCP server。
- ChatGPT 与 Codex 使用同一个公共插件目录,但不同产品 surface 的可用性和安装控制仍分开。
- 插件安装不等于 connector 已授权,也不等于 runtime 获得文件、网络或命令权限。
- 安装后应在新 conversation/thread 中验证,以确保 Skills 和工具清单按新状态加载。
## 本机 Desktop 安装包证据
### 应用身份
```text
Application: /Applications/ChatGPT.app
Bundle identifier: com.openai.codex
Version: 26.727.51351
```
虽然应用目录名是 `ChatGPT.app`bundle identifier 与资源都表明它同时承载 Codex Desktop surface。不能依据 Finder 展示名推断产品边界。
### 关键资源目录
```text
/Applications/ChatGPT.app/Contents/Resources/app.asar
/Applications/ChatGPT.app/Contents/Resources/codex
/Applications/ChatGPT.app/Contents/Resources/codex-code-mode-host
/Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled
/Applications/ChatGPT.app/Contents/Resources/skills
/Applications/ChatGPT.app/Contents/Resources/native
```
取证时体积约为:
```text
app.asar: 208 MiB
plugins/openai-bundled: 69 MiB
skills: 372 KiB
```
这些目录表明 bundled plugin 不是写死在前端卡片中的 metadata,而是随应用发布的真实包、脚本、Skills、assets 与本地运行资源。
### bundled marketplace
入口文件:
```text
Resources/plugins/openai-bundled/.agents/plugins/marketplace.json
```
取证时包含以下插件:
- `sites`
- `browser`
- `chrome`
- `computer-use`
- `messages`
- `reminders-macos`
- `record-and-replay`
- `latex`
- `deep-research`
- `visualize`
marketplace 只描述来源、安装策略、鉴权时机与类别;插件自己的能力和展示信息位于各包的 `.codex-plugin/plugin.json`
### 真实插件包样例
Browser 插件使用以下结构:
```text
plugins/browser/
├── .codex-plugin/plugin.json
├── assets/
├── docs/
├── scripts/
└── skills/
└── control-in-app-browser/SKILL.md
```
manifest 包含稳定 identity、版本、描述、作者、Skills 路径与 `interface` 元数据;能力脚本和大体积依赖留在插件包内,不进入 renderer bundle。
### 用户态目录
本机可见的相关位置包括:
```text
~/Library/Application Support/Codex
~/Library/Application Support/OpenAI/Codex/NativeMessagingHosts
```
前者主要是 Chromium/Electron profile、缓存和 browser partition;后者是 Codex 原生消息桥接位置。它们不是 plugin manifest 事实源。
官方文档给出的本地作者目录约定为:
```text
Repo marketplace: $REPO_ROOT/.agents/plugins/marketplace.json
Personal marketplace: ~/.agents/plugins/marketplace.json
Common plugin source: $REPO_ROOT/plugins/<name> 或 ~/.codex/plugins/<name>
```
`marketplace.json``source.path` 才是解析依据,示例目录不是必须硬编码的唯一位置。
## Codex 源码证据
### 领域 owner
| 路径 | 责任 |
| ------------------------------------------------------- | ------------------------------------------------------- |
| `codex-rs/plugin` | manifest、plugin ID、source authority、resolved plugin |
| `codex-rs/core/plugins` | marketplace discovery、安装、卸载、remote catalog、配置 |
| `codex-rs/utils/plugins` | mention syntax、namespace、MCP connector helper |
| `codex-rs/tools/request_plugin_install.rs` | agent 发起的插件安装请求 |
| `codex-rs/app-server/src/request_processors/plugins.rs` | 对 GUI/CLI 暴露 current plugin API |
| `codex-rs/tui/src/chatwidget/plugins.rs` | 列表、搜索、详情、安装、启停、marketplace UI |
Codex 的 resolver 先生成 inert descriptor,再由上层决定装配哪些能力。路径资源保留 source authority,避免把来自其他 environment 的资源误当成本机路径。
### App Server 方法
Codex current 方法包括:
```text
marketplace/add
marketplace/remove
marketplace/upgrade
plugin/list
plugin/installed
plugin/read
plugin/install
plugin/uninstall
```
`plugin/list` 支持 local、vertical、workspace-directory、shared-with-me、created-by-me-remote 等 marketplace kind`plugin/read` 返回 Skills、Hooks、Apps、MCP servers 与完整展示信息。
### 产品状态不是单一布尔值
Codex protocol 分开表达:
- `installed`
- `enabled`
- `installPolicy`: `AVAILABLE | INSTALLED_BY_DEFAULT | NOT_AVAILABLE`
- `authPolicy`: `ON_INSTALL | ON_USE`
- `availability`: `AVAILABLE | DISABLED_BY_ADMIN`
- `disabledReason`
- `version``localVersion`
Lime v2 必须保留这些正交维度,不能再用 `installed/disabled/readiness` 的前端拼装近似替代。
### UI 交互证据
Codex TUI snapshot 和 Desktop `app.asar` 中的真实页面资源共同证明以下 surface 已存在:
- Plugins 目录页与插件详情页
- All / Installed / source marketplace 分区
- 搜索
- 安装数量摘要
- 安装、卸载、启用、禁用
- 管理员分配与管理员禁用
- Skills、Hooks、Apps、MCP servers 披露
- 来源、鉴权时机、隐私和条款披露
- `@plugin` mention 与安装建议
Desktop bundle 中对应资源包括 `plugins-page``plugin-detail-page``plugin-picker-menu-content``plugins-settings``skills-page`
## Lime 当前差异
### 包标准分叉
Lime 当前 App Server 要求:
```text
plugin.json
schemaVersion: lime.plugin.package.v1
contributions.runtime
contributions.workbench
```
随后由 `plugin_packages/plugin_manifest.rs` 投影成前端使用的另一份 manifest。这个流程与 Codex `.codex-plugin/plugin.json` 不是同一标准,且该文件已超过 1000 行,混合了解析、projection、兼容和 UI/runtime 推断。
### 前端承担过多事实合并
`src/features/plugin/marketplace/marketplaceRegistryLoader.ts` 同时读取 marketplace、installed state、manifest、setup state、runtime profile 和 readiness,再组合页面 registry。它实际成为第二个插件事实源。
### 自定义 worker 与 UI runtime
现有 `electron/pluginRuntimeTaskHost.ts``pluginTaskWorker.ts``src/features/plugin/runtime/**` 和 App Server plugin worker runtime 形成了插件专用执行/投影体系。它与 RuntimeCore、MCP、Skills 和 Right Surface 的 current owner 重叠。
### 产品范围膨胀
旧路线图把插件消费、独立应用壳、发布中心、云端上传、历史工作区和内容生产 runtime 同时纳入一个计划,导致 App Center 既像 marketplace,又像应用启动器和发布后台。
## 对 Plugin v2 的直接结论
1. 直接采用 `.codex-plugin/plugin.json`,不设计旧 manifest compat 层。
2. App Server 返回完整 PluginSummary/PluginDetailRenderer 不再合并 registry。
3. 插件能力装配复用 Skills、MCP、Hooks 与 RuntimeCore owner。
4. `@plugin` mention 是 Claw 的首要调用入口;目录页不是唯一入口。
5. Right Surface 承载 MCP/App UI 或结果投影,不启动第二套插件业务后端。
6. 发布平台与消费者 runtime 分离;v2 先完成本地、repo、bundled 与 remote catalog 消费闭环。
7. 旧文档和 implementation tracker 冻结为历史参考;有效洞察提炼进入 v2,但旧文档不再作为 current 实现依据或独立继续演进。
## 研究限制
当前会话没有暴露 `computer-use` skill 要求的 Node REPL 工具,因此没有通过自动化直接点击本机 ChatGPT/Codex Desktop 的 Plugins 页面。视觉与交互结论来自:
- 官方文档
- 本机真实应用包和 bundle 资源
- Codex TUI snapshot
- Codex App Server protocol 与实现
这足以确定产品结构和协议,但正式 UI 实现前仍需补一轮真实 Desktop 交互对照截图,作为设计输入而不是运行时事实源。
@@ -0,0 +1,227 @@
# Plugin v2 产品合同
状态:`proposed`
## 产品定义
Plugin 是一个可安装、可治理、可在对话中调用的能力包。它把相关 Skills、MCP servers/apps、Hooks、assets 和展示元数据组织为一个稳定 identity。
Plugin 不是:
- 单独一张应用中心卡片
- 任意网页 URL
- 另一套 Agent runtime
- 只有 UI、没有可调用能力的 iframe
- 把 Skill、MCP 和 Hook 复制进自己的私有协议
## 当前主对象
App Center 的主对象是“插件包”;Claw 的主对象仍是“当前 thread/turn”。插件只向 thread 提供可发现能力,不能把用户从对话主线带到第二套任务系统。
## 核心用户目标
1. 找到可信的插件并理解它能做什么。
2. 看清来源、权限、鉴权时机和外部影响后安装。
3. 在 Claw 中通过自然语言或 `@plugin` 明确调用。
4. 在右侧查看插件返回的结构化 UI、浏览器或结果详情。
5. 随时启停、更新或卸载,并知道残留数据和授权如何处理。
## 来源模型
| 来源 | 说明 | 默认信任 |
| ---------- | ---------------------------------------------------- | ------------------------ |
| bundled | 随 Lime 安装包发布并由 release 签名保护 | 高,但仍受运行时权限约束 |
| repo | 当前 workspace 的 `.agents/plugins/marketplace.json` | 仅当前 workspace |
| personal | 用户级 marketplace 和本地插件源 | 当前用户 |
| configured | 用户显式添加的 Git/local/npm marketplace | 按来源和校验结果 |
| workspace | 工作区管理员或远端目录提供 | 按 workspace policy |
| shared | 其他成员分享或链接安装 | 需要展示发布者和分享边界 |
来源只决定发现和安装路径,不自动授予 connector、文件、网络、命令或 external action 权限。
## 生命周期状态
### 目录可见性
- `available`:可安装。
- `installedByDefault`:由 bundled/workspace policy 默认安装。
- `notAvailable`:目录中可解释,但当前不能安装。
- `disabledByAdmin`:管理员禁止使用。
### 本地状态
- `notInstalled`
- `installing`
- `installedDisabled`
- `installedEnabled`
- `updateAvailable`
- `uninstalling`
- `failed`
### 鉴权状态
- `notRequired`
- `requiredOnInstall`
- `requiredOnUse`
- `authorizing`
- `authorized`
- `authorizationFailed`
这些状态必须保持正交。一个插件可以“已安装但未授权”,也可以“管理员默认安装但用户禁用”。
## 关键流程
### 目录安装
```text
打开 App Center
-> 选择来源或搜索
-> 打开详情
-> 查看能力、权限、来源与条款
-> 安装
-> 必要时授权 connector
-> 新建或刷新 thread
-> 在 Claw 中调用
```
### Claw 中安装建议
```text
用户输入 @plugin 或提出明确目标
-> installed projection 未找到能力
-> 目录 projection 存在可安装候选
-> 展示安装建议
-> 用户确认安装
-> 必要时授权
-> 新 thread/turn 生效
```
Agent 可以请求安装,但不能绕过安装确认、管理员策略或 connector 授权。
### 右侧工作区
```text
插件工具返回 structured content / UI resource
-> RuntimeCore 记录 tool item
-> GUI projection 解析 surface descriptor
-> Claw Right Surface 打开对应面板
-> 用户在同一 thread 继续确认或操作
```
右侧关闭后,对话与 tool item 仍可恢复;右侧不能成为独立于 thread 的隐藏状态源。
## App Center 信息架构
一级结构:
- 全部
- 已安装
- 官方/bundled
- Workspace
- Repo/Local
- 用户添加的 marketplace
主要动作:
- 未安装:`安装`
- 已安装且启用:`在 Claw 中使用`
- 已安装且禁用:`启用`
- 有更新:`更新`
- 详情更多操作:`卸载``查看来源``管理授权`
不在卡片上同时放置发布、审核、导出、删除数据等低频管理动作。
## 详情披露合同
详情必须展示:
- 名称、图标、开发者、版本
- 一句话能力说明与完整说明
- 来源 marketplace 和安装策略
- Skills
- MCP servers/apps
- Hooks 及触发事件
- 需要的 connector 与鉴权时机
- Read/Write/Interactive 等能力标签
- 网站、隐私政策和服务条款
- 安装/启停/更新/卸载状态
技术细节可以折叠,但外部写入、敏感数据访问和不可逆动作不能隐藏。
## Claw 兼容合同
### Composer
- `@` picker 同时显示已安装插件和明确标注的可安装建议。
- 已安装插件可插入 `plugin://<plugin-id>` 结构化 mention,显示文本使用 `@DisplayName`
- 未安装插件不能伪装成已可调用;选中后进入安装确认。
- mention identity 必须贯穿 turn request、tool selection、trace 和历史恢复。
### Runtime
- Plugin mention 只增加插件选择上下文,不绕过 Skill/MCP/Tool 选择与权限检查。
- 未显式 mention 时仍可按 Skill 描述和工具 metadata 自动发现。
- 同一插件的 Skills、MCP 与 Hooks 使用同一 plugin identity 和 source authority。
### Right Surface
- 支持 MCP/App UI resource。
- 支持 browser intent 与现有 Browser Right Surface。
- 支持结构化结果、文件预览和审查视图。
- 不支持插件自行启动未受管 iframe、任意本地 server 或 renderer-to-worker 私有协议。
## 启用与安装的语义
- 安装:把受校验的包和来源写入 installed store。
- 启用:允许其能力进入新 thread 的 discovery/activation projection。
- 授权:允许 connector 代表当前身份访问外部系统。
- 运行权限:决定当前 turn 是否可执行文件、网络、命令或外部写操作。
这四件事不得合并为一个“可用”按钮。
## 错误与恢复
每个失败必须给出可执行恢复动作:
| 失败 | 恢复动作 |
| -------------------- | ------------------------------------ |
| marketplace 无法读取 | 重试、刷新来源、查看具体来源错误 |
| manifest 无效 | 显示字段/路径错误,不允许安装 |
| 版本不兼容 | 显示所需 host/runtime 版本 |
| connector 未授权 | 进入授权,不重新安装插件 |
| 管理员禁用 | 说明策略来源,不提供无效启用按钮 |
| MCP 启动失败 | 查看连接诊断、重试或禁用该 connector |
| 安装中断 | 回滚 staging,不留下半安装状态 |
| 卸载有残留 | 显示残留位置和重试入口 |
## 首版范围
P0/P1 必须支持:
- bundled/repo/personal/configured local marketplace
- `.codex-plugin/plugin.json`
- skills-only plugin
- MCP plugin,包括 auth-on-install 与 auth-on-use
- Hooks 声明和信任披露
- App Center 目录、详情、安装、启停、卸载
- `@plugin` mention
- Claw Right Surface 的 MCP/App UI
后续再支持:
- public universal directory 发布
- workspace sharing 与 groups
- remote billing/commerce
- scheduled tasks
- 插件作者 analytics
## 产品验收问题
发布前必须能明确回答:
1. 用户正在处理哪个插件、来自哪里?
2. 它为什么可用、不可用或被禁用?
3. 安装后还需要什么授权?
4. 在 Claw 中如何调用?
5. 右侧内容属于哪个 thread/turn/tool item
6. 卸载后包、配置、授权和数据各自如何处理?
@@ -0,0 +1,293 @@
# Plugin v2 包、Marketplace 与安装合同
状态:`proposed`
## 唯一包结构
Plugin v2 只接受 Codex-compatible package
```text
my-plugin/
├── .codex-plugin/
│ └── plugin.json
├── skills/
│ └── my-workflow/
│ ├── SKILL.md
│ ├── references/
│ ├── scripts/
│ └── assets/
├── .mcp.json # 可选
├── .app.json # 可选,兼容 MCP/App connection 映射
├── hooks.json # 可选
└── assets/ # 可选,logo/icon/screenshots
```
旧根目录 `plugin.json``schemaVersion: lime.plugin.package.v1``contributions.runtime``contributions.workbench` 不进入 v2 parser。
## Manifest 合同
最小 skills-only 插件:
```json
{
"name": "meeting-follow-up",
"version": "1.0.0",
"description": "Turn meeting notes into decisions and next steps",
"skills": "./skills/"
}
```
完整示例:
```json
{
"name": "acme-projects",
"version": "1.2.0",
"description": "Review and update Acme projects",
"author": {
"name": "Acme"
},
"homepage": "https://example.com/docs",
"repository": "https://example.com/repo",
"license": "MIT",
"keywords": ["projects", "planning"],
"skills": "./skills/",
"mcpServers": "./.mcp.json",
"apps": "./.app.json",
"hooks": "./hooks.json",
"interface": {
"displayName": "Acme Projects",
"shortDescription": "Review projects and blockers",
"longDescription": "Inspect project status, review blockers, and update approved fields.",
"developerName": "Acme",
"category": "Productivity",
"capabilities": ["Read", "Write", "Interactive"],
"websiteURL": "https://example.com",
"privacyPolicyURL": "https://example.com/privacy",
"termsOfServiceURL": "https://example.com/terms",
"defaultPrompt": ["Show blockers in my active projects"],
"brandColor": "#0F766E",
"composerIcon": "./assets/composer.png",
"logo": "./assets/logo.png",
"logoDark": "./assets/logo-dark.png",
"screenshots": ["./assets/overview.png"]
}
}
```
## 字段规则
### Identity
- `name`:必填,kebab-case,作为 package identity 与 namespace 基础。
- `version`:发布包必填,严格 semver;本地开发包也建议必填。
- 同一 marketplace 内 `name` 唯一。
- installed identity 不能只用 `name`;至少组合 source identity 与 plugin name,远端包还保留 remote plugin ID。
### Components
- `skills`:包内相对路径,默认发现 `skills/`
- `mcpServers`:包内 `.mcp.json` 路径或内联 server object。
- `apps`:包内 `.app.json`,指向已注册或可解析的 app/connector declaration。
- `hooks`:包内 hook config;启用前必须做信任和事件披露。
- 所有路径都必须解析到 package root 内,禁止绝对路径、`..` 越界、符号链接逃逸与大小写/Unicode normalization 冲突。
### Interface
- `displayName` 是用户可见名称;缺失时回退 `name`
- `shortDescription` 用于列表;`longDescription` 用于详情。
- `defaultPrompt` 最多展示 3 条,每条最多 128 字符。
- logo、icon 和 screenshots 必须存在于包内。
- `brandColor` 只用于局部品牌提示,不覆盖 Lime 全局主题。
- 外部写入或交互能力必须来自实际组件声明,不允许只靠 marketing metadata 宣称。
## Marketplace 合同
Repo marketplace
```text
$REPO_ROOT/.agents/plugins/marketplace.json
```
Personal marketplace
```text
~/.agents/plugins/marketplace.json
```
示例:
```json
{
"name": "local-team",
"interface": {
"displayName": "Team Plugins"
},
"plugins": [
{
"name": "acme-projects",
"source": {
"source": "local",
"path": "./plugins/acme-projects"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
}
]
}
```
### 支持来源
- `local`marketplace root 内的相对目录。
- `url`:插件位于 Git repository 根目录。
- `git-subdir`:插件位于 Git repository 子目录,可带 `ref``sha`
- `npm`:包名、版本范围和 HTTPS registry;下载时禁止执行 lifecycle scripts。
- `remote`workspace/public catalog 返回的不可伪造 remote identity。
### Policy
- `installation`: `AVAILABLE | INSTALLED_BY_DEFAULT | NOT_AVAILABLE`
- `authentication`: `ON_INSTALL | ON_USE`
- `products`:仅作为显式 surface override;默认省略。
- `category`:目录分组,不参与权限判断。
## Discovery 顺序
App Server 根据当前 workspace roots 计算 discovery
1. release-bundled marketplace
2. 当前 repo marketplace
3. personal marketplace
4. 用户显式配置的 marketplace snapshots
5. workspace/remote catalog
顺序只影响展示与冲突诊断,不允许用“后读覆盖前读”静默替换同 identity 包。冲突必须返回 source-aware diagnostics。
Renderer 不读取任何 marketplace 文件,也不自行扫描 `plugins/`
## Installed Store
installed store 至少保存:
```text
pluginId
pluginName
sourceKind
sourceIdentity
marketplaceIdentity
remotePluginId?
installedVersion
contentDigest
installedAt
enabled
installPolicy
authPolicy
packageRootLocator
componentSummary
```
不要把 connector access token、OAuth refresh token 或用户数据写进 installed record。凭证进入统一 secret/credential owner。
## 安装事务
```text
resolve source
-> fetch/copy into isolated staging
-> validate marketplace policy
-> validate manifest and all paths
-> compute package digest
-> inspect Skills/MCP/Apps/Hooks
-> produce install review
-> user confirmation
-> atomic materialization
-> write installed store
-> start required auth flow
-> emit pluginsChanged
```
### Install review
确认界面必须披露:
- 插件名称、版本、开发者和来源
- 安装策略与 auth policy
- Skills 数量与名称
- MCP servers/apps
- Hook 事件与信任状态
- Read/Write/Interactive 能力
- 需要的外部授权
- 包 digest 或签名状态
### 原子性
- staging 与 final package root 必须在同一可原子替换的存储边界。
- 任一步失败都清理 staginginstalled store 保持原值。
- 更新使用 side-by-side staging,验证通过后替换,不原地覆盖运行中的包。
- Runtime 使用 version/digest pin;正在运行的 turn 不切换到半更新组件。
## 启用与刷新
- 安装后默认是否启用由 marketplace policy 和用户确认共同决定。
- enabled state 只影响新 thread/turn 的能力 snapshot。
- 改变启用状态后发出 `pluginsChanged`Claw 提示新 conversation/thread 生效。
- 不在运行中的 turn 动态增删工具定义,避免 model context 与 tool registry 不一致。
## 更新
更新检查比较 source version、installed version 与 local materialized version
- 同 digest 不重复安装。
- semver 降级需要显式确认。
- source identity 改变视为新安装,不静默更新。
- Git branch/range 可以刷新,但 installed record 必须 pin 到解析后的 commit/digest。
- npm 安装不运行 `preinstall/postinstall` 等生命周期脚本。
## 卸载
卸载分四类资源:
1. package materialization
2. installed/enabled configuration
3. connector authorization
4. plugin-owned user data
默认卸载只删除 1 和 2;3 和 4 必须在 UI 中单独说明和选择。管理员默认安装的插件不可由用户卸载,只能按 policy 允许时禁用。
卸载必须先停止该插件的 MCP process/hooks,等待 terminal 或明确中止,再删除 package。正在运行的 turn 保留历史 item,不因卸载被改写。
## 平台路径
生产代码不得直接拼接 `$HOME``~/Library` 或 Windows profile 路径。所有位置通过统一 app paths/platform API 解析:
- bundled source:应用 Resources 内只读目录
- marketplace snapshots:用户 app data/config owner
- installed packages:用户 app data 下的 plugin store
- staging/cache:统一 cache/temp owner
- secrets:系统 keychain/credential owner
macOS 和 Windows 使用相同逻辑 identity,不要求物理路径一致。
## 安全边界
- manifest 与 marketplace 都是不可信输入。
- 路径必须 canonicalize 后验证仍在 authority root 内。
- remote download 必须有大小、超时、content-type、digest/signature 与重定向限制。
- archive 解包防 zip-slip、symlink/hardlink escape、大小炸弹和文件数量炸弹。
- MCP/Hook/Script 的存在不意味着可执行;执行受 runtime policy 和 approval 控制。
- UI resource 使用受控 origin/CSP,不接受任意 `file://` 或 unrestricted localhost。
- 日志只保存 identity、digest、阶段和错误码,不保存 token、完整用户数据或 tool result。
## 明确删除的旧合同
Plugin v2 完成迁移后,下列合同为 `dead / forbidden-to-restore`
- `lime.plugin.package.v1`
- 根 `plugin.json``contributions.runtime/workbench`
- `app.runtime.yaml` 作为插件 runtime 主入口
- Renderer 自行 projection manifest
- 以本地完整路径作为跨层 plugin identity
- 安装记录中混合凭证、运行状态和 UI readiness
@@ -0,0 +1,285 @@
# Plugin v2 架构与命令合同
状态:`implementation in progress`
## 唯一产品链
```text
Electron Desktop Host
-> app_server_handle_json_lines
-> App Server plugin JSON-RPC
-> Plugin catalog/install owner
-> Skills / MCP / Hooks activation
-> RuntimeCore Thread/Turn/Item
-> Renderer projection
-> App Center / Claw / Right Surface
```
Electron 只负责 desktop host、文件选择、窗口/webContents 和受控原生能力;它不解析 marketplace、不决定 plugin readiness,也不运行第二套 plugin task runtime。
## Owner 分工
| Owner | 责任 | 禁止承担 |
| ------------------------ | ------------------------------------------------------- | ----------------------------- |
| `app-server-protocol` | plugin/marketplace JSON-RPC schema、notification | 文件扫描、安装实现 |
| App Server plugin domain | discovery、read、install、update、uninstall、projection | provider request、UI 状态拼装 |
| app paths/repository | 跨平台目录、installed store、原子写入 | 运行时工具执行 |
| Skills owner | Skill discovery、namespace、selection、prompt fragment | marketplace 安装 |
| MCP owner | server lifecycle、OAuth、tools/resources/apps | plugin catalog UI |
| Hooks owner | hook config、trust、lifecycle enforcement | 任意插件私有 worker |
| RuntimeCore | thread/turn snapshot、工具与审批、item projection | 安装包文件操作 |
| Renderer gateway | typed JSON-RPC client、notification subscription | manifest 解析、registry 合并 |
| App Center | 目录、详情和生命周期操作 | catalog 事实源 |
| Claw Right Surface | UI/result projection | 独立业务后端 |
## 协议方法
Plugin v2 采用下列 current 方法:
```text
marketplace/add
marketplace/remove
marketplace/upgrade
plugin/list
plugin/installed
plugin/read
plugin/install
plugin/uninstall
plugin/enabled/set
```
`plugin/enabled/set` 是 Lime 需要的明确扩展,用于表达本地启停;其语义不得混入 install/uninstall。
后续按真实需求再加入:
```text
plugin/update
plugin/share/save
plugin/skill/read
```
不要把旧 `pluginInstalledSave``pluginLocalPackageInspect``pluginUiRuntimeStart` 等方法机械改名后继续保留同一实现。
## 核心 DTO
### PluginSummary
```text
id
remotePluginId?
name
version?
localVersion?
source
installed
installedAt?
enabled
installPolicy
installPolicySource?
authPolicy
availability
disabledReason?
interface?
keywords[]
```
### PluginDetail
```text
marketplaceName
marketplacePath?
summary
description?
skills[]
hooks[]
apps[]
mcpServers[]
screenshots[]
sourceDiagnostics[]
```
### Source authority
资源 locator 必须携带 owner
```text
Bundled { releaseId, relativePath }
Workspace { workspaceId, relativePath }
Local { marketplaceId, relativePath }
Installed { pluginId, digest, relativePath }
Remote { remotePluginId, releaseId, resourceId }
```
跨 App Server 边界不暴露可被 Renderer 任意读取的绝对路径。需要展示来源时返回 sanitized label 和可审计 source descriptor。
## Discovery 与缓存
App Server 每次 list 以 workspace roots、configured marketplaces、release bundle 和 remote policy 为输入,生成 source-aware projection。
缓存必须以以下维度键控:
```text
workspace roots
marketplace snapshot versions
installed store revision
workspace/admin policy revision
remote catalog revision
```
不得只按 plugin name 缓存,也不得由 Renderer localStorage 充当 installed fact。
## 安装时序
```text
Renderer plugin/install
-> App Server resolve marketplace entry
-> installer staging + validation
-> optional confirmation/authorization request
-> atomic store update
-> activate component descriptors
-> pluginsChanged notification
-> Renderer refresh list/detail
```
安装请求必须包含 marketplace identity 与 plugin name,不能只传 URL 或任意文件路径。导入本地目录时先建立显式 local marketplace/source authority,再走同一安装流程。
## Runtime 装配
### Thread snapshot
启动或恢复 thread 时,RuntimeCore 获取当前 enabled plugins snapshot
- plugin identity/version/digest
- enabled Skills roots
- MCP server/app declarations
- trusted Hooks
- policy and auth readiness
snapshot 写入 thread/runtime metadata,只保存 marker 和 identity,不保存完整 Skill body、secret 或 MCP token。
### Turn
- 显式 `@plugin` mention 进入 turn input 的结构化 selection。
- Skills selector 可从 enabled plugin skill roots 发现候选。
- MCP tool registry 只暴露已启动、已授权且 policy 允许的 server tools。
- Hooks 在 tool/runtime owner 的统一 lifecycle 上执行。
- 安装/启停变化不修改正在运行的 turn tool schema。
### MCP current 装配
Plugin v2 的 MCP 声明已进入统一 runtime owner
```text
LocalAppDataSource
-> list_mcp_runtime_server_specs
-> RuntimeBackend::ensure_thread_mcp_runtime_if_available
-> AgentRuntimeState::ensure_mcp_runtime
-> McpThreadRuntime
-> McpClientManager
```
当前合同如下:
- manifest `mcpServers` 可以指向包内 JSON 文件,也可以直接声明 inline objectmanifest 未声明时发现 package root 的 `.mcp.json`
- `.mcp.json` 同时接受 `{ "mcpServers": {...} }` 和直接 server map,并复用 `lime_mcp::McpServerConfig` 解析。
- stdio server 默认 `cwd` 是 installed package root;显式相对或绝对 `cwd` canonicalize 后必须仍位于该 root 内。
- disabled Plugin 不生成 runtime spec;单个无效 server fail closed,但不丢弃有效 sibling。
- runtime server name 固定为 `plugin__<plugin-id>__<server-id>`tool name 继续由统一 MCP owner 投影为 `mcp__<runtime-server-name>__<tool-name>`
- 用户 MCP 与 Plugin MCP 重名时保留用户配置并跳过冲突的 Plugin server。
- activation descriptor 记录可审计的 `runtimeCapabilities.mcpServers`,但 `mcpBindings` 不伪造 wildcard tool binding;具体工具只能在 server initialize/list tools 后进入 snapshot。
该实现不新增 Plugin worker、第二套 MCP manager 或私有 IPC。Apps 与 Hooks 的 current lifecycle 装配仍待完成。
### Item projection
tool item 必须保留:
```text
pluginId?
skillName?
mcpServerName?
toolName
callId
approvalId?
surfaceDescriptor?
status
```
这样 Claw timeline、Right Surface 和历史恢复使用同一 identity。
## Right Surface 合同
插件可以通过标准 tool result 声明 surface
```text
McpAppResource { server, resourceUri, csp, initialState }
BrowserTarget { url, browserFamily?, ownership }
StructuredResult { rendererKind, dataRef }
FilePreview { pathRef, mimeType }
```
Renderer 只根据受支持 descriptor 打开已有 Right Surface owner。插件不能注册任意 React component import、任意 iframe src 或 Electron IPC channel。
UI 内的动作回到 MCP tool call/elicitation/approval;不建立 `pluginUiRuntimeStart -> plugin worker -> custom event` 私有闭环。
## Auth 与权限
```text
Plugin installed
!= Connector authorized
!= MCP ready
!= Tool allowed
!= External write approved
```
App Server projection 分别返回这些状态。授权由 MCP/OAuth owner 执行,secret 由统一凭证 owner 保存;approval 由 tool-runtime/agent-runtime 执行。
## Notifications
至少需要:
```text
plugin/changed
marketplace/changed
mcpServer/statusUpdated
mcpServer/oauthLoginCompleted
skills/changed
```
通知只表示 revision 改变,Renderer 收到后重新读取 projection;不要把整份 catalog 通过事件重复广播。
## 错误模型
错误按 owner 分类:
- `marketplace_*`:来源不可达、格式错误、冲突
- `package_*`manifest、路径、digest、archive、版本
- `policy_*`:管理员禁用、计划不满足、产品不支持
- `auth_*`:未授权、scope 不足、登录失败
- `runtime_*`MCP/Hook/Skill activation 失败
- `surface_*`UI resource/CSP/renderer 不支持
错误必须包含稳定 code、sanitized message、retryability 与 recovery action;不向 Renderer 泄露 secret 或未清理的本地绝对路径。
## 架构删除线
以下方向禁止进入 Plugin v2
- Renderer -> filesystem 直接扫描 plugin package
- Renderer -> Electron plugin worker 作为业务后端
- App Center registry 与 RuntimeCore 各维护一份 enabled plugins
- 插件包注册任意 IPC/capability 名称
- 生产路径回退 mock capability host
- 为旧 `lime.plugin.package.v1` 新增 compat wrapper
- 用 `pluginId@tenant` 字符串拼装代替结构化 source identity
## 架构确认项
实现阶段的责任开发者需要在执行计划与 PR 描述确认:
- [ ] App Server 是 catalog/installed 唯一事实源
- [ ] RuntimeCore 使用同一 plugin identity snapshot
- [ ] Right Surface 没有第二套业务后端
- [ ] 旧 manifest 和 worker 入口已迁出或删除
- [ ] macOS/Windows 路径走统一平台 owner
- [ ] Gate B trace 能证明 current JSON-RPC 与用户可见状态
@@ -0,0 +1,242 @@
# Plugin v2 App Center 与 Claw Surface
状态:`proposed`
## 设计原则
复刻 Codex 的信息架构和状态语义,不复刻其视觉主题。Lime 继续使用当前浅色、安静、桌面应用感的设计语言,不引入 Codex/ChatGPT 的深色背景、品牌渐变或超大圆角。
## 页面类型
App Center 是目录型列表工作台,不是营销 landing page,也不是发布后台。
主对象:插件包。
当前阶段:发现、检查、安装、启停或更新。
主操作:根据状态显示一个明确动作;低频动作进入更多菜单或详情页。
## 页面结构
```text
┌────────────────────────────────────────────────────────────┐
│ Plugins [搜索] [来源] [刷新] │
├──────────────┬─────────────────────────────────────────────┤
│ 全部 │ 已安装 3 / 可用 18 │
│ 已安装 │ │
│ 官方 │ 插件列表 │
│ Workspace │ icon 名称 / 描述 / 来源 / 状态 [主动作] │
│ Repo / Local │ icon 名称 / 描述 / 来源 / 状态 [主动作] │
│ 自定义来源 │ │
│ + 添加来源 │ │
└──────────────┴─────────────────────────────────────────────┘
```
宽窗口使用左侧来源 rail + 列表;窄窗口把来源折叠为下拉菜单。不要把每个来源做成等权大卡片。
## 顶部工具区
- 标题与安装摘要保持紧凑。
- 搜索框支持名称、描述、keyword、Skill、MCP server。
- 来源筛选优先使用下拉或左侧 rail。
- 刷新使用图标按钮。
- 添加 marketplace 是次级动作,不与安装主动作竞争。
- 不显示大段“插件是什么”说明;帮助信息进入 tooltip/help panel。
## 列表项
每项固定展示:
- 40px 左右 logo;无 logo 时使用稳定生成的 fallback,不复用同一默认图标。
- display name。
- 一行 short description。
- source label。
- installed/enabled/admin/auth/update 中最关键的一个状态。
- 单一主动作。
- 更多菜单。
状态与动作映射:
| 状态 | 主动作 | 次级动作 |
| ----------------- | -------------- | ---------------------- |
| available | 安装 | 查看详情 |
| installing | 安装中 | 取消,仅在可安全取消时 |
| installedEnabled | 在 Claw 中使用 | 禁用、卸载、来源 |
| installedDisabled | 启用 | 卸载、来源 |
| updateAvailable | 更新 | 在 Claw 中使用、卸载 |
| authRequired | 连接 | 禁用、卸载 |
| disabledByAdmin | 无 | 查看策略说明 |
| failed | 重试 | 查看错误、卸载 |
卡片/行内不同时出现“打开、安装、更新、发布、审核、删除”五六个按钮。
## 来源导航
与 Codex 对齐的来源分区:
- 全部插件
- 已安装
- Lime Bundled/Official
- Workspace
- Shared with me
- Repo marketplace
- Personal/Local
- 用户配置 marketplace
- 添加 marketplace
空来源不必强制显示;Workspace 可在远端加载中显示稳定 skeleton,失败时只影响该来源,不清空其他 marketplace。
## 搜索
- 输入即时过滤当前 projection,不重新触发安装状态读取。
- 搜索为空时展示目录;无结果时显示当前 query 和清空入口。
- source tab 与 query 同时生效。
- 搜索结果仍展示 source 和 policy,不能因过滤丢失安全语境。
## 详情页/抽屉
详情不是卡片里的嵌套卡片。使用独立详情视图或右侧详情 pane:
```text
Header: logo / name / developer / version / primary action
Summary: long description / source / policy / auth
Capabilities: Skills / MCP Apps / Hooks
Examples: default prompts
Trust: website / privacy / terms / digest or signature
Diagnostics: only when blocked or failed
```
详情中的能力列表可以折叠,但外部写入、Hook 事件和授权要求默认可见。
## 安装确认
确认弹窗只在真正安装前出现,内容聚焦影响:
- 来源与开发者
- Read/Write/Interactive 能力
- Skills、MCP/apps、Hooks 数量
- auth-on-install/on-use
- 隐私和条款链接
按钮:`取消``安装`。不要把“安装并运行、安装并打开、安装并授权”混成多个竞争按钮;安装后按 auth policy 进入下一状态。
## Claw Composer
### `@` Picker
Picker 分组:
- 已安装插件
- 可安装建议
- 其他现有 mention 类型
每个插件项展示 logo、名称、简述和状态。已安装插件选中后插入结构化 mention;可安装项选中后打开安装确认,不直接插入可执行 mention。
### mention 显示
显示:`@Acme Projects`
结构化值:
```text
kind: plugin
pluginId
sourceIdentity
displayName
version?
```
发送时不得退化成只靠文本正则重新识别。
### 新 thread 提示
安装、启停或更新后,如果当前 thread 的 runtime snapshot 已建立,显示简短提示:“插件将在新对话中生效”。提供“新建对话”动作,不静默修改当前 turn 的 tool schema。
## Claw Right Surface
### 支持 surface
- MCP/App UI:结构化查看、比较、编辑、确认。
- Browser:复用 Browser Right Surface。
- Structured result:表格、列表、详情或 diff。
- File/media preview:复用现有 workspace preview。
### 布局
- Right Surface 与 timeline 保持同一 thread/turn context。
- 顶栏显示插件名、当前工具/资源名、刷新/关闭等明确命令。
- 主内容全高、可滚动,不放在装饰性外层卡片中。
- 窄窗口改为可切换主视图,不压缩到不可读的双栏。
- UI resource 自身不覆盖 Lime 全局标题栏和导航。
### 生命周期
- 打开 surface 不等于重新调用 tool。
- 刷新需要新的 tool/resource read 时,走标准 MCP 调用并记录 item。
- 用户提交写操作时走 approval/elicitation。
- thread 恢复后从 item/read model 恢复 surface descriptor。
- 插件卸载后历史 item 保留只读结果;不能再发起新动作。
## 视觉规则
- 主题继续使用 Lime 当前 color tokens。
- 卡片圆角不超过现有应用中心规范;列表优先,避免大块营销卡。
- 一个插件只使用自己的 logo/brand color 做局部识别,不染色整页。
- 状态色:成功 emerald、信息 sky/slate、提醒 amber、错误 rose/red。
- 主动作使用稳定按钮宽度,异步状态不引发布局跳动。
- 不使用大面积渐变、半透明主表面、嵌套卡片或说明型 hero。
## 响应式约束
至少验证:
- 1024x768:来源可折叠,列表名称与主动作不重叠。
- 1280x800:标准 rail + 列表 + 可选详情 pane。
- 1440x900:提高信息密度,不放大字体。
- 390x844:来源变下拉,详情占主视图,按钮文案可换行但不溢出。
稳定尺寸:
- logo/icon 固定方形。
- 工具按钮固定 `h/w`
- 状态列与主动作有 min/max width。
- 长名称和长 source label 使用 truncate + title/tooltip。
## 可访问性与国际化
- 所有 icon button 有 `aria-label``title`
- tab/segmented control 使用正确 selected state。
- 安装、启停使用 `aria-busy`/disabled 状态并保留可读原因。
- 键盘可完成搜索、切换来源、打开详情、安装和返回。
- 用户文案覆盖 `zh-CN``zh-TW``en-US``ja-JP``ko-KR`
- plugin manifest 内容按原文展示;系统状态和动作本地化。
## 稳定 DOM 合同
建议 test id
```text
plugin-directory
plugin-source-selector
plugin-search-input
plugin-list
plugin-list-item
plugin-primary-action
plugin-detail
plugin-install-dialog
plugin-mention-option
plugin-right-surface
```
测试使用 identity 属性,例如 `data-plugin-id``data-source-kind``data-installed`,不依赖样式 class 或中文文本定位关键对象。
## UI 退出条件
- [ ] 用户 5 秒内能看出当前来源、安装状态和下一步动作
- [ ] installed/auth/admin/update 状态不互相覆盖
- [ ] App Center 与 Claw 显示同一 plugin identity 和版本
- [ ] `@plugin` 可安装建议不会伪装为已可用能力
- [ ] Right Surface 从 Thread/Turn/Item 恢复
- [ ] 1024 与移动宽度无文字/按钮重叠
- [ ] 五语言和键盘操作有稳定回归
- [ ] 真实 Electron Gate B 覆盖安装、mention、tool call 与 surface
@@ -0,0 +1,170 @@
# Plugin v2 迁移与清理账本
状态:`historical-docs-frozen / implementation-migration-pending`
更新时间:2026-08-04
## 目标
本文件定义 Plugin v2 从旧包标准、旧前端 registry 和插件专用 worker 迁入 current 主链的唯一清理顺序。它是删除账本,不是兼容层设计。
迁移完成后的唯一产品链为:
```text
.codex-plugin/plugin.json / marketplace.json
-> App Server plugin domain
-> Skills / MCP / Hooks
-> RuntimeCore / Tool Runtime
-> Thread / Turn / Item projection
-> App Center / Claw / Right Surface
```
## 清理原则
1. 先建立 current owner 和回归,再迁移调用,最后删除旧 owner。
2. 不增加 `v1 | v2` 双读、manifest 自动转换或 renderer fallback。
3. 旧代码只有在调用者清零、fixture 已迁移、回流守卫生效后才能删除。
4. 发布、审核和商业化平台不是 Plugin consumer runtime 的组成部分;需要时进入独立路线图。
5. 删除动作必须保留可审计证据,并遵循仓库危险操作确认要求。
6. 重大架构落地时,同一变更集必须更新 `internal/aiprompts/architecture.md` 和对应执行计划。
## 分类定义
| 分类 | 允许行为 | 禁止行为 |
| ---------------------- | ------------------------------ | --------------------------------------------- |
| `current` | 扩展、修复、补测试 | 绕过 owner 建第二套实现 |
| `deprecated` | 迁出调用、补观测、为删除做准备 | 新增能力、新增调用者、作为 fallback |
| `dead` | 删除、加禁止回流守卫 | 恢复、包装、继续维护 |
| `historical reference` | 查阅、引用、向 v2 提炼有效决策 | 直接作为 current 实现或验收依据、独立继续演进 |
| `historical evidence` | 查阅和复核历史结果 | 冒充当前 Gate B 证据、覆盖 current 结果 |
## Current:保留并对齐
| 能力 | Current owner | v2 动作 |
| ---------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------- |
| Plugin protocol | `lime-rs/crates/app-server-protocol`、App Server request processor | 增加 Codex-compatible marketplace/plugin 方法与 projection |
| Plugin domain | `lime-rs/crates/app-server` 内聚后的 plugin owner | 拆分 discovery、manifest、install store、activation,避免继续堆入单文件 |
| Skills | 现有 Skills discovery/loader owner | 按 plugin identity 和 source authority 装配 |
| MCP servers/apps | 现有 MCP owner | 复用 server lifecycle、auth、tool/resource 和 App UI 协议 |
| Hooks | RuntimeCore / tool runtime 对应 lifecycle owner | 只接受 manifest 声明并进入统一权限与事件链 |
| Agent execution | `agent-runtime`、RuntimeCore、`tool-runtime` | Plugin 仅贡献能力,不拥有 agent loop |
| Read model | App Server、`thread-store` 与 projection package | 记录 plugin identity、tool item、surface descriptor 和恢复状态 |
| Desktop bridge | Electron preload/IPC 到 `app_server_handle_json_lines` | 只转发 current JSON-RPC,不承接 catalog 和业务状态 |
| App Center | `src/features/plugin/ui/**` 的 current 页面 owner | 改为消费 App Server projection,不再合并 registry |
| Claw | composer、streaming、thread/read model owner | 增加结构化 `@plugin` mention 与调用状态 |
| Right Surface | `RightSurfaceRegistry` 及 surface owner | 承载 MCP/App UI、Browser、结构化结果和文件预览 |
## Deprecated:只迁出
以下区域在 v2 迁移期间可以被读取和测试,但不得新增产品能力:
| 旧事实或实现 | 当前问题 | 迁出目标 |
| -------------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------- |
| 根目录 `plugin.json``lime.plugin.package.v1` | 与 Codex 包标准分叉 | `.codex-plugin/plugin.json` |
| `contributions.runtime` / `contributions.workbench` | 把 agent runtime 和 UI host 私有化 | Skills、MCP servers/apps、Hooks、Right Surface descriptor |
| `app.runtime.yaml` 主入口 | 引入插件专用 worker 生命周期 | RuntimeCore/MCP/Skill current activation |
| `plugin_packages/plugin_manifest.rs` 旧 projection | 解析、兼容、UI/runtime 推断混在一起且超过文件治理阈值 | 小职责 manifest parser 与 protocol projection |
| `marketplaceRegistryLoader.ts` | Renderer 合并 marketplace、installed、manifest、readiness | App Server `plugin/list``plugin/installed` |
| `src/features/plugin/manifest/**` 旧解析链 | Renderer 直接理解安装包标准 | Renderer 只使用 protocol-generated types |
| `src/features/plugin/install/**` 中本地事实拼装 | installed/setup/cache 多源并存 | App Server installed store 与原子安装事务 |
| `src/features/plugin/runtime/**` 私有 runtime/projection | 与 RuntimeCore、MCP、read model 重叠 | current runtime 与统一 item projection |
| `pluginUiRuntimeStart/Status/Stop` 命令族 | 第二套 UI runtime 生命周期 | MCP/App UI resource 与 Right Surface lifecycle |
| `src/features/plugin/publish/**` | 发布后台混入插件消费主线 | 独立发布平台路线图或删除 |
| `src/features/plugin/packaging/**` | 自定义桌面应用打包与 Plugin 包混为一体 | 只保留 Codex-compatible 包校验所需能力 |
`deprecated` 不等于立即整目录删除。每个子域必须先完成调用图、测试与数据所有权核对,再按最小写集迁出。
## Dead:迁移后删除
满足对应退出条件后,下列路径或语义应删除:
| Dead surface | 候选路径 | 删除前置条件 |
| --------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| Electron plugin worker | `electron/pluginRuntimeTaskHost.ts``electron/pluginTaskWorker.ts` | 所有执行已进入 App Server/RuntimeCoreIPC 调用清零 |
| App Server plugin worker | `lime-rs/crates/app-server/src/runtime/plugin_worker_*``plugin_task_runtime.rs` | runtime tests 与 read model fixture 已迁移到 current tool/MCP item |
| 独立 Plugin UI host | `src/features/plugin/runtime/uiExtensionHost.ts` 及私有 bridge | MCP/App UI 可在 Right Surface 完成同等用户流程 |
| Renderer capability mock fallback | `src/features/plugin/sdk/MockCapabilityHost.ts` 等生产可达 mock | mock 仅存在于测试夹具且构建守卫证明生产不可达 |
| 旧 package fixtures | `src/features/plugin/testing/fixtures/**/plugin.json``app.runtime.yaml` | fixture 已换成 `.codex-plugin/plugin.json` 与 MCP/Skill/Hook 样例 |
| 旧技术标准 | `internal/tech/plugin/lime-plugin-package-v1.md` | v2 contract 已成为唯一文档入口,入站引用清零 |
| 旧 smoke scripts | `smoke:plugin-ui-runtime-*``smoke:plugin-runtime-*` 对应脚本 | Gate B current plugin fixture 覆盖安装到 Right Surface 闭环 |
不要用空壳 facade 保留旧命令名。调用者迁完后直接删除命令、类型、fixture 和脚本入口。
## 冻结保留的历史文档
以下文档保留在仓库中,但冻结为 `historical reference``historical evidence`
| 分类 | 文件 | 保留价值 |
| ------------ | -------------------------------------------------------------------------------------------- | ---------------------------------- |
| 历史设计参考 | `prd.md``architecture.md``interface-contracts.md``technical-baseline.md` | 产品需求、分层、接口和旧实现约束 |
| 历史体验参考 | `prototype.md``prototype.html``user-operations-guide.md``history-product-workspace.md` | 页面结构、用户流程、恢复和运维体验 |
| 历史跟踪 | `implementation-plan.md` | 已实施切片、问题背景和验证记录 |
| 历史证据 | `e2e-evidence.md``evidence/plugin-productization-e2e-summary.json` | 旧链路的 E2E 结果与局限 |
| 独立范围输入 | `deverlop/plugin-publish-center-prd.md``deverlop/plugin-publish-limecore-server-plan.md` | 发布平台需求,供未来独立路线图提炼 |
只有 `internal/roadmap/plugin/README.md``internal/roadmap/plugin/v2/**` 可以继续演进 Plugin current 路线。历史文档若发现仍有效的决策,应在 v2 对应文档中重述并记录当前依据,而不是直接恢复其事实源地位。
## 入站引用迁移
已知需要同步的 current 入站引用:
| 来源 | 动作 |
| ------------------------------------------- | --------------------------------------------------------------------------------- |
| `internal/roadmap/browser/README.md` | 将旧 PRD、architecture、contracts、baseline 链接改到 v2 产品、架构和 surface 合同 |
| `internal/roadmap/agentworkbench/README.md` | 保持指向根 `plugin/README.md`,由根索引导航 v2 |
| `internal/roadmap/zuanjia/README.md` | 保持指向根 `plugin/README.md`,不复制 Plugin 规则 |
| `internal/tech/plugin/README.md` | v2 实施时改为 deprecated 声明,最终删除旧标准入口 |
历史执行日志中出现旧路径不需要机械重写;如果文档仍被 `docs:boundary` 当作 current 输入,则应由边界规则显式排除历史记录,而不是伪造过去的证据。
## 数据与状态迁移
本仓库没有外部用户和兼容负担,因此不做长期格式迁移器。开发态本地数据按以下策略处理:
1. v2 installed store 使用新的 schema/version 和独立目录。
2. 首次启动发现旧 installed/setup/cache 时,只显示“一次性清理旧插件数据”诊断,不自动转换为已安装 v2 插件。
3. bundled 插件由当前 marketplace 重新解析与安装,不继承旧 readiness。
4. connector 凭证由既有 auth owner 管理;删除插件只解除引用,不擅自删除共享凭证。
5. 删除旧缓存前列出 namespace、预计体积和恢复边界,并走用户确认。
6. thread 历史保留旧 tool/item 文本投影,但不恢复已删除 worker;恢复时明确显示历史能力不可重跑。
## 回流守卫
V2-0 和 V2-6 至少建立以下静态守卫:
```text
forbidden manifest: schemaVersion = lime.plugin.package.v1
forbidden fields: contributions.runtime, contributions.workbench
forbidden entry: app.runtime.yaml
forbidden production imports: MockCapabilityHost, mockCapabilityProfile
forbidden commands: pluginUiRuntimeStart, pluginUiRuntimeStatus, pluginUiRuntimeStop
forbidden renderer behavior: fs/path scan of plugin package or marketplace
```
守卫应扫描生产代码、package scripts、协议 catalog 和 current 文档;测试 fixture 例外必须精确到文件,不能按目录放行。
## 单切替换顺序
```text
冻结旧标准并加“禁止新增”守卫
-> 建立 v2 parser/protocol/store
-> 迁移 Skills/MCP/Hooks activation
-> 迁移 App Center 与 @plugin
-> 迁移 Right Surface 与历史投影
-> 迁移 Gate B fixture
-> 清零旧调用和旧命令
-> 删除 worker、旧 manifest、旧脚本并冻结历史文档
-> 加“禁止恢复”守卫
```
任一步出现主链缺口都应修复 current owner,不允许临时回落到旧 worker 或 renderer registry。
## 删除完成条件
- `rg` 无生产代码引用旧 manifest、旧 worker 命令和旧 registry loader。
- package scripts 不再暴露旧 runtime smoke。
- protocol/client/catalog/fixture 中不存在旧命令。
- App Center 与 Claw 只消费 App Server v2 projection。
- Gate B current fixture 覆盖安装、调用、Right Surface、恢复、卸载。
- `governance:legacy-report` 将旧能力标记为 `dead/deleted/forbidden-to-restore`
- 根 Plugin README 以 v2 为 current 导航,并将旧文档明确分区为历史参考。
@@ -0,0 +1,245 @@
# Plugin v2 实施计划
状态:`active / P1-P5 core implemented`
更新时间:2026-08-05
## 主目标
以最短可验证路径建立 Codex-compatible Plugin consumer runtime,并在同一主链内完成 App Center、Claw `@plugin` 和 Right Surface。实施期间不维持 v1/v2 产品双轨。
## 写集纪律
每个阶段开始前,在 `internal/exec-plans/` 建立或更新执行计划,声明:
- 本阶段目标和退出条件
- 精确写集与脏热区避让
- current/deprecated/dead 变化
- 架构图确认
- 定向测试与 Gate B 证据路径
- 阻塞和未验证原因
并行开发时,protocol、App Server plugin domain、RuntimeCore、App Center、Claw/Right Surface 五个写集不得由不同变更同时重写同一事实。
## 阶段总览
| 阶段 | 主结果 | 前置 | 完成信号 |
| ---- | ----------------------------- | ----------- | ----------------------------------------------------- |
| V2-0 | 冻结标准与回流守卫 | v2 文档确认 | 旧标准禁止新增,执行计划落盘 |
| V2-1 | Manifest/marketplace/protocol | V2-0 | App Server 可列出和读取 bundled/repo/personal 插件 |
| V2-2 | 原子安装与 installed store | V2-1 | install/update/uninstall 可恢复且无半安装态 |
| V2-3 | RuntimeCore activation | V2-2 | Skills/MCP/Hooks 在新 thread 可追踪装配 |
| V2-4 | App Center | V2-1、V2-2 | 页面只消费 App Server projection |
| V2-5 | Claw 与 Right Surface | V2-3 | `@plugin` 到 tool item/UI surface 闭环 |
| V2-6 | 旧实现删除与文档冻结 | V2-4、V2-5 | 旧 worker、manifest、registry、命令清零,历史文档冻结 |
| V2-7 | 跨平台与 release gate | V2-6 | macOS/Windows 与 Gate B 全通过 |
## 当前实施快照
| 阶段 | 状态 | 2026-08-05 事实 |
| ---- | ------- | ------------------------------------------------------------------------------------------------- |
| V2-0 | DONE | v2 文档、唯一 manifest 与 current owner 已冻结 |
| V2-1 | DONE | typed catalog/read/install/installed/enabled protocol 已接入 |
| V2-2 | PARTIAL | staging、digest、幂等与原子替换已接入;崩溃恢复/残留清理仍待补 |
| V2-3 | PARTIAL | Plugin Skills 与 MCP 已装配;独立 Apps/Hooks 仍待补 |
| V2-4 | PARTIAL | App Center 已只读 typed catalog;真实安装点击与窄窗证据仍待补 |
| V2-5 | PARTIAL | `plugin://`、MCP App Right Surface 与 reload 恢复已接入;picker 点击、Browser/file surface 仍待补 |
| V2-6 | PENDING | 未物理删除旧实现;删除前必须再次取得用户确认 |
| V2-7 | PARTIAL | macOS core Gate B 已通过;Windows 与完整 P0/release matrix 未通过 |
当前完成度为 `75%`。当前主目标已从“建立 parser/protocol”转为“补齐真实用户点击、卸载历史、cold restore 与跨平台证据”。
## V2-0:冻结旧标准
### 写集
- `internal/roadmap/plugin/v2/**`
- `internal/roadmap/plugin/README.md`
- `internal/exec-plans/<plugin-v2-plan>.md`
- 既有 governance 检查的最小扩展点
### 工作项
1. 确认 `.codex-plugin/plugin.json` 是唯一 manifest。
2. 确认 App Server 是 catalog、installed、enabled、policy、auth readiness 唯一事实源。
3. 把旧 `lime.plugin.package.v1` 和 worker 命令列为 deprecated。
4. 增加“禁止新增旧字段/新调用者”的窄范围守卫。
5. 记录架构图确认,标出 current owner 与待删除 owner。
### 退出条件
- v2 系列文档通过格式和链接检查。
- 旧文档不再被根 README 作为 current 导航。
- 新代码无法继续引入旧 manifest 字段或生产 mock。
## V2-1Manifest、Marketplace 与 Protocol
### 建议 owner
```text
lime-rs/crates/app-server/src/plugins/
manifest.rs
marketplace.rs
resolver.rs
projection.rs
lime-rs/crates/app-server-protocol/
plugin v2 request/response types
```
最终目录以现有 crate 边界为准;不要新建与既有 owner 重叠的 crate。
### 工作项
1. 解析 `.codex-plugin/plugin.json`,校验 identity、version、relative paths 和 capability declarations。
2. 解析 repo/personal marketplace,并支持 bundled/configured/remote source descriptor。
3. 保留 source authority,禁止把远端路径当本地路径。
4. 实现 `marketplace/add|remove|upgrade``plugin/list|installed|read` current protocol。
5. 返回完整 `PluginSummary` / `PluginDetail`,包含 installed、enabled、policy、auth、availability 和版本字段。
6. 生成或共享 protocol typesRenderer 不手写镜像类型。
### 退出条件
- bundled、repo、personal 三类来源可被同一 list API 表达。
- 非法 manifest、路径越界、重复 ID、冲突版本返回稳定错误码。
- Renderer 没有参与目录扫描或 manifest 解析。
- `npm run test:contracts` 通过。
## V2-2:安装 Store 与事务
### 工作项
1. 定义 installed record、source lock、content digest、enabled state 和 schema version。
2. 下载或复制到 staging,完成大小、路径、digest、manifest 和 policy 校验后原子切换。
3. 实现 `plugin/install``plugin/uninstall` 和 update flow。
4. 安装与 connector auth 分离;`ON_INSTALL` 只触发授权流程,不把授权写成安装成功的必要伪状态。
5. 卸载前关闭能力装配,清理包数据,保留共享凭证和 thread 历史。
6. 崩溃恢复时回滚 staging 或完成已提交事务,不留悬空状态。
### 退出条件
- 安装中断、磁盘不足、digest 不一致和重复请求均有确定结果。
- update 失败保留上一可用版本。
- uninstall 后 package、index、activation projection 无残留。
- macOS 与 Windows 路径测试通过。
## V2-3Skills、MCP 与 Hooks 激活
### 工作项
1. 将 installed+enabled 插件解析为 inert capability descriptors。
2. 新 thread 创建或明确 reload 时生成 activation snapshot。
3. Skills 进入现有 skill discoveryMCP servers/apps 进入现有 MCP lifecycleHooks 进入统一事件与权限 owner。
4. 为每个 capability 携带 plugin ID、marketplace、version、source authority 和 digest。
5. tool call、hook invocation、auth challenge 和失败事件进入 Thread/Turn/Item read model。
6. enable/disable 不篡改运行中的 turn;新状态从下一个规定边界生效。
### 退出条件
- 不显式 mention 时,Skill/MCP 仍可按描述被正常发现。
- 显式 mention 时只收窄插件上下文,不绕过权限。
- 插件 MCP 启停与普通 MCP 共用 lifecycle 和诊断。
- 运行路径不进入 plugin worker。
## V2-4App Center 重做
### 写集
- `src/features/plugin/ui/**`
- App Server plugin gateway/client
- 五语言资源与定向测试
### 工作项
1. 页面改为 `All / Installed / source marketplace` 信息架构。
2. 增加搜索、来源筛选、详情、安装、启停、更新、卸载和 auth 入口。
3. 详情披露 Skills、Hooks、Apps、MCP servers、source、auth、privacy、terms。
4. 保持 Lime 当前主题、密度、颜色、圆角和 icon 语言,只复刻 Codex 的产品结构与状态语义。
5. 通过按钮或下拉收纳低频动作;不恢复发布后台和独立 runtime page。
6. 删除 renderer registry 合并、manifest parsing 和 mock fallback。
### 退出条件
- App Center 只调用 current plugin gateway。
- 加载、空、错误、安装中、管理员禁用、待授权、可更新状态完整。
- `zh-CN``zh-TW``en-US``ja-JP``ko-KR` 文案齐全。
- 桌面与窄窗口无溢出、重叠或布局变形。
## V2-5Claw Mention 与 Right Surface
### 工作项
1. `@` picker 接入 installed projection,并把可安装建议与可调用插件明确区分。
2. composer 写入结构化 `plugin://<plugin-id>` mention,不依赖显示名反解析。
3. turn request、RuntimeCore trace、tool item 和历史恢复贯穿同一 plugin identity。
4. MCP/App UI resource 注册到 Right SurfaceBrowser intent 复用 browser tab;文件和结构化结果复用既有 surface。
5. 后台结果进入 pending badge,不抢用户当前右侧 tab。
6. surface action 回流 App Server current action/turn contract,不直连 provider、文件系统或插件 worker。
### 退出条件
- `@plugin` 可从 composer 触发真实 MCP/Skill 调用。
- Right Surface 显示与当前 turn/tool item 对应的真实结果。
- 关闭、重开和恢复 thread 后 identity 与 surface state 一致。
- 插件 UI 不启动任意 iframe、本地 server 或私有 worker bridge。
## V2-6:旧路删除
### 工作项
1. 生成旧 manifest、命令、import、script 和 fixture 调用图。
2. 迁移最后调用者和测试。
3. 删除 Electron/App Server plugin worker、plugin UI runtime、renderer registry 和旧包 parser。
4. 删除旧 smoke scripts、package scripts 和 fixture。
5. 将 v1 技术标准与旧路线图冻结为历史参考,current 导航只进入 v2。
6. 将“禁止新增”守卫升级为“禁止恢复”守卫。
### 退出条件
- [05-migration-and-cleanup.md](./05-migration-and-cleanup.md) 的 dead 清单全部满足前置条件。
- `rg`、protocol catalog、package scripts 和 build graph 均无旧入口。
- `governance:legacy-report` 无 current/compat 误判。
- current Gate B fixture 在删除后仍通过。
删除属于高风险操作,执行前必须单独列出精确文件并取得明确确认。
## V2-7:跨平台与 Release Gate
### 工作项
1. 在 macOS 验证 bundled、repo、本地安装、auth、调用、UI、更新和卸载。
2. 在 Windows 验证路径、权限、进程、归档和原子替换语义。
3. 验证应用升级后的 bundled marketplace upgrade 与 user-installed 保留策略。
4. 完成恶意包、损坏包、管理员禁用和网络中断测试。
5. 运行完整 Gate B 并记录证据摘要。
### 退出条件
- [07-verification-contract.md](./07-verification-contract.md) 的 P0 场景全部通过。
- `npm run verify:local` 通过。
- manifest/workspace/Electron 版本变化时 `npm run verify:app-version` 通过。
- release evidence 可追踪 plugin/thread/turn/item/surface identity。
## 测试扩展策略
| 风险 | 最小验证 | 扩展条件 |
| ------------------ | -------------------------------------- | --------------------------------------------- |
| 文档/守卫 | 格式、链接、`git diff --check` | 影响 docs boundary 时跑 contracts |
| Parser/protocol | Rust 单元、schema fixture、contracts | 影响多 crate 时跑 related tests |
| Install store | 单元、崩溃恢复、路径安全 | 涉及 OS 行为时双平台 |
| Runtime activation | current fixture、MCP/Skill integration | 影响 Thread/Turn/Item 时扩大 read model tests |
| GUI | component tests、GUI smoke | 影响 bridge/Right Surface 时必须 Gate B |
| 清理 | 旧引用扫描、contracts、current fixture | 删除共享 owner 时跑 verify:local |
## 不允许的实施捷径
- 先在前端写一份 v2 marketplace 假数据等待后端补齐。
- 用 feature flag 长期保留 v1/v2 两套 production path。
- 把 Codex manifest 转换成 `lime.plugin.package.v1` 后继续走旧 worker。
- 以“兼容 Desktop”为由在 Electron main process 重建业务状态。
- 以 UI demo 代替真实 App Server/RuntimeCore 调用证据。
- 将发布中心、审核后台或商业化能力塞回 P0 consumer runtime。
## 完成度口径
阶段完成度只按退出条件计算,不按代码量或页面可见程度计算。任何阶段存在 production mock、legacy command hit、身份断链或未记录的跨 owner fallback,完成度不得超过 `90%`,也不得进入 release evidence。
@@ -0,0 +1,316 @@
# Plugin v2 验收合同
状态:`active / macOS P0 core Gate B passed / release pending`
更新时间:2026-08-05
## 验收目标
证明 Plugin v2 不是静态目录页或 renderer fixture,而是从真实安装来源经过 Electron/App Server/RuntimeCore 到 Claw 用户可见状态的生产主链。
Gate A 用于快速验证浏览器投影;Gate B 才是功能验收。任何 P0 场景只有 Gate A、组件测试或 mock 证据时,一律记为未通过。
## 质量层级
| 层级 | 证明内容 | 能否替代 Gate B |
| ----------- | ------------------------------------------------------------------------------ | --------------- |
| Unit | parser、state reducer、transaction、projection 的局部行为 | 否 |
| Contract | protocol、Electron IPC、preload、gateway、catalog 一致 | 否 |
| Integration | App Server 与 filesystem/MCP/RuntimeCore 协作 | 否 |
| Gate A | 浏览器或静态 fixture 的页面信息架构与布局 | 否 |
| Gate B | 真实 Electron、真实 bridge、真实 App Server、真实 runtime/read model 和可见 UI | 是 |
| Release | Gate B 加跨平台、升级、恶意输入和残留检查 | 是,最终门禁 |
## Gate B 必须穿过的链路
```text
Electron Desktop Host
-> preload / IPC
-> app_server_handle_json_lines
-> App Server JSON-RPC
-> plugin domain / installed store
-> RuntimeCore / MCP / Skills / Hooks
-> Thread / Turn / Item read model
-> renderer gateway
-> App Center / Claw / Right Surface
```
证据必须能关联:
```text
pluginId + marketplaceId + version/digest
threadId + turnId + itemId/toolCallId
surfaceId/resourceUri
```
若任一层使用 renderer mock、测试专用 bridge、旧 plugin worker 或手工注入 read model,则该次运行不能标记 Gate B。
## 当前 Gate B 证据
2026-08-05 已 fresh 通过单一 Plugin v2 Electron fixture
```bash
npm run smoke:plugin-v2-current-electron-fixture
```
该 fixture 强制从源码重建 App Server sidecar,并从真实 GUI 实际经过:
```text
App Center sidebar
-> plugin/list / bundled Browser identity
-> window.electronAPI.dialog.open
-> app:dialog:open / Electron dialog.showOpenDialog
-> install review / confirm
-> plugin/install
-> plugin/read
-> plugin/installed
-> App Center menu disable / enable
-> plugin/enabled/set(false / true)
-> Claw model selector
-> Plus / Plugins / installed Plugin option
-> plugin://mcp-elicitation-plugin structured mention
-> thread/start
-> GUI send / turn/start
-> McpThreadRuntime / McpClientManager
-> mcp__plugin__mcp-elicitation-plugin__demo__release_check
-> mcpServer/elicitation/request
-> Renderer 表单提交
-> provider final
-> thread/read
-> mcpResource/read
-> MCP App Right Surface / WebContentsView
-> Renderer reload restore
-> App Center uninstall confirm
-> plugin/uninstall / plugin/installed cleared
-> reopen original Thread
-> historical mention / tool item / Right Surface unavailable state
```
自动断言结果:
```text
proofLevel=Gate B
backendMode=runtime
electronPreloadBridge=true
appServerHandleJsonLinesSeen=true
appCenterBundledListVisible=true
appCenterBundledIdentityStable=true
nativeDirectoryDialogObserved=true
appCenterInstallReviewVisible=true
appCenterInstallCompleted=true
appCenterEnableToggleCompleted=true
clawFixtureModelSelected=true
clawPluginPickerVisible=true
clawPluginBadgeVisible=true
clawStructuredMentionObserved=true
clawThreadTurnItemIdentityStable=true
rendererFormVisible=true
rendererConfirmedSubmitted=true
dynamicToolCanonicalCompleted=true
runtimeInitializeProtocolVersion=2025-06-18
missingRequiredMethods=[]
legacyMcpCommandsSeen=[]
pluginWorkerHitCount=0
productionMockFallbackHitCount=0
providerFinalTextObserved=true
mcpLedgerAccepted=true
mcpAppRightSurfaceVisible=true
mcpAppResourceReadCount=4
mcpAppHtmlLoadCount=4
mcpAppToolCallCount=1
mcpAppRestoredAfterReload=true
mcpAppHistoryUnavailableAfterUninstall=true
mcpAppCanonicalIdentityStable=true
uninstallViaAppCenterCompleted=true
installedProjectionClearedAfterUninstall=true
historyReadableAfterUninstall=true
historyMcpRuntimeNotRestartedAfterUninstall=true
historyProviderNotReexecutedAfterUninstall=true
historyToolNotReexecutedAfterUninstall=true
consoleErrors=[]
```
证据位于 `.lime/qc/gui-evidence/plugin-v2-current-electron-fixture/`,包含 summary、脱敏 raw evidence 与 7 张当前 Electron 截图。人工复核确认 App Center、安装 review、Claw picker/mention、Right Surface、cold restore、卸载确认和卸载后历史均非空,无重叠、裁切、变形或主题偏离。
summary 记录应用版本、平台/架构、Electron 版本、repository commit、起止时间,以及 `plugin/source/version/digest + session/thread/turn/user item/tool item/tool call/surface/resource` 身份。MCP App resource/HTML 累计计数按“首次打开与显式恢复 2 次、Renderer reload 3 次、跨进程 cold restore 4 次、卸载后保持 4 次”精确断言;MCP tool call 始终仅 1 次。卸载后只恢复 canonical 历史 item 与 surface identityGUI 明确显示“仅保留历史”,不挂载 WebContentsView,不读取 MCP resource,也不重启 MCP runtime、provider 或 tool。
当前 claim boundary 覆盖 bundled 列表、真实 Electron 目录选择与 App Center 安装/启停/卸载、Claw picker 与结构化 mention、manifest 选择的默认 `.mcp.json` 自动启动、真实 MCP tool、elicitation、provider final、canonical tool terminal、`thread/read`、MCP App Right Surface、Renderer reload、跨进程 cold restore 和卸载后历史不可重跑状态。`.mcp.json` 是 MCP 配置文件,唯一 Plugin manifest 仍是 `.codex-plugin/plugin.json`
该证据不证明 repo/personal marketplace、授权、更新、管理员策略、Windows release gate 或五语言窄窗矩阵已通过。
## P0 场景矩阵
| ID | 场景 | 当前 Gate B | 操作 | 必须观察到的结果 |
| ----- | ------------------------- | ----------- | ------------------------------------------------------------ | ------------------------------------------------------------------------- |
| GB-01 | bundled 列表 | PASS | 启动 Desktop,打开 App Center | `plugin/list` 返回 bundled source;卡片 identity/version 与 manifest 一致 |
| GB-02 | repo/personal marketplace | PENDING | 打开含 repo marketplace 的 workspace,再添加 personal source | 来源分区正确;同 ID 冲突按 authority/policy 给出确定结果 |
| GB-03 | 本地安装 | PASS | 从详情安装未安装插件 | staging 校验、原子提交、installed projection 和按钮状态一致 |
| GB-04 | 安装时授权 | PENDING | 安装 `ON_INSTALL` connector 插件 | 安装与授权状态分开;取消授权不伪装成未安装 |
| GB-05 | 使用时授权 | PENDING | 调用 `ON_USE` 插件 | 首次真实调用时出现授权;授权后原 turn 按合同继续或明确重试 |
| GB-06 | 启用/禁用 | PASS | 禁用已安装插件,创建新 thread,再启用 | 新 thread 不发现禁用能力;启用后的新边界恢复发现 |
| GB-07 | `@plugin` mention | PASS | 在 Claw picker 选择已安装插件并发送 | composer、turn、trace、tool item 使用同一 plugin ID |
| GB-08 | MCP tool call | PASS | 调用插件 MCP 工具 | 真实 MCP lifecycle、权限、流式 tool item 和结果完成 |
| GB-09 | MCP/App UI | PASS | 工具返回 UI resource | Right Surface 打开对应 App UIsurface identity 与 tool item 一致 |
| GB-10 | Browser surface | PENDING | 插件发出 browser intent | 复用 Right Surface browser tab,不弹出第二右栏或系统浏览器 |
| GB-11 | 历史恢复 | PASS | 关闭并恢复 thread | plugin/tool/surface 投影可恢复;不偷偷重跑旧动作 |
| GB-12 | 更新 | PENDING | 安装有新版本的插件 | 新版本校验后原子替换;失败保留旧版本;新 thread 使用新 digest |
| GB-13 | 卸载 | PASS | 卸载已使用插件 | catalog/installed/activation 清理;历史仍可读;共享 auth 不误删 |
| GB-14 | 管理员禁用 | PENDING | 加载 disabled-by-admin 插件 | 可解释但不能启用/调用;UI 不展示无效动作 |
`PARTIAL` 只表示底层 current contract 或受控 fixture 已覆盖部分步骤,不能替代该行要求的真实用户动作。当前 release gate 仍为未通过。
GB-01、03、06、07、08、09、11、13 已通过 macOS 真实 Electron current 链路。其余 P0 场景、Windows 和五语言窄窗矩阵仍阻塞 release。
## 安全与鲁棒性场景
| ID | 输入/故障 | 预期 |
| ------ | -------------------------------------- | ----------------------------------------------------- |
| SEC-01 | 缺失或非法 `.codex-plugin/plugin.json` | 拒绝解析,错误定位到字段,不写 installed store |
| SEC-02 | `../`、绝对路径、symlink 逃逸 | 拒绝越过 package/source authority |
| SEC-03 | archive bomb、超大文件、文件数爆炸 | 在解包前/中按预算停止并清理 staging |
| SEC-04 | digest/signature 不一致 | 拒绝安装或更新,保留上一可用版本 |
| SEC-05 | 重复 ID、source spoofing | 按 marketplace authority 处理,不静默覆盖 |
| SEC-06 | 安装中断或进程崩溃 | 重启后无半安装态,可重试 |
| SEC-07 | 卸载时文件占用 | 明确残留和重试动作,不谎报成功 |
| SEC-08 | MCP server 启动失败 | installed 保持,readiness 失败可诊断,不回落旧 worker |
| SEC-09 | connector token 失效 | 进入重新授权,不重装插件,不泄漏 token |
| SEC-10 | UI resource 非受信 origin | Host policy 拒绝或隔离,不允许任意本地 bridge |
| SEC-11 | Hook 请求高风险动作 | 进入统一权限/确认,不因插件来源绕过 |
| SEC-12 | 管理员策略运行中变化 | 新请求停止,用户看到策略原因,历史状态保持可读 |
## UI 验收
### App Center
- All、Installed 和 marketplace 分区可切换,状态不互相污染。
- 搜索覆盖名称、描述、开发者和能力关键词。
- 卡片只保留主动作;启停、更新、卸载、授权和来源等低频动作使用详情或菜单。
- 详情展示 Skills、Hooks、Apps、MCP Servers、source、auth、privacy 和 terms。
- 使用 Lime 当前主题,不复制 Codex 配色;按钮、图标、间距和状态反馈遵循 Lime design language。
- 360px 级窄窗口、常规桌面和宽屏下无文字溢出、卡片变形、重叠或横向滚动。
- 加载、空、错误、离线、安装中、待授权、管理员禁用和更新失败状态均可恢复。
### Claw 与 Right Surface
- `@` picker 中“已安装可调用”和“可安装建议”视觉与行为不同。
- mention 显示名变化不影响稳定 plugin identity。
- 后台插件结果只增加 pending badge,不抢当前 tab。
- MCP/App UI、Browser、文件和结构化结果复用现有 Right Surface registry。
- 关闭右侧不丢 thread/tool item;恢复时不会自动重复外部动作。
- surface action 回流 current action/turn contract,网络面板中不存在私有 plugin worker 请求。
### 多语言
以下 locale 必须同时验收:
```text
zh-CN
zh-TW
en-US
ja-JP
ko-KR
```
至少对最长文案 locale 做窄宽度截图,不能只验证 key 存在。
## 协议与身份断言
Gate B fixture 应自动断言:
1. `plugin/list``plugin/install``plugin/read``plugin/installed``plugin/uninstall` 请求经过 App Server JSON-RPC。
2. Desktop 调用经过 preload/IPC 和 `app_server_handle_json_lines`
3. Renderer 未直接读取 filesystem、marketplace JSON 或 plugin manifest。
4. runtime item 包含 plugin identity、capability identity 和 source authority。
5. UI surface 的 `resourceUri/surfaceId` 可以反查同一 tool item。
6. thread restore 后 identity 不按 display name 重建。
7. legacy command hit 为 `0`
8. production mock fallback hit 为 `0`
9. plugin worker process/IPC hit 为 `0`
## 建议证据包
```text
.lime/qc/gui-evidence/plugin-v2-gate-b/<run-id>/
summary.json
bridge-events.jsonl
app-server-requests.jsonl
runtime-items.jsonl
plugin-state-before.json
plugin-state-after.json
screenshots/
app-center-list.png
plugin-detail.png
claw-mention.png
tool-running.png
right-surface.png
restored-thread.png
```
`summary.json` 至少记录:应用版本、平台、commit、scenario、plugin/source/version/digest、thread/turn/item/surface IDs、legacy/mock hit count、开始结束时间和最终 verdict。
证据中不得包含 token、cookie、用户输入的敏感字段、绝对凭证路径或完整环境变量。
## 最低验证命令
按改动范围执行,最终 release gate 至少包括:
```bash
npm run test:contracts
npm run test:rust:related -- <affected-paths...>
npm run smoke:agent-runtime-current-fixture
npm run verify:gui-smoke
npm run governance:legacy-report
npm run verify:local
npm run verify:app-version
```
Plugin v2 的单一 current Gate B 命令为:
```bash
npm run smoke:plugin-v2-current-electron-fixture
```
该命令稳定前不得删除旧 fixture 的最后覆盖;稳定后应删除旧 plugin runtime/UI worker smoke,而不是永久并存。
## 失败分类
| 分类 | 判定 | 处理 |
| --------------------------- | ------------------------------------- | ------------------------------------------ |
| Product failure | 用户流程、状态、文案或恢复不符合合同 | 修复后重跑对应场景和相邻场景 |
| Bridge failure | preload/IPC/App Server 不通或走旁路 | Gate B 失败,禁止用 Gate A 替代 |
| Runtime failure | MCP/Skill/Hook 未进入 current item 链 | 修复 owner,不回落 plugin worker |
| Projection failure | runtime 成功但 GUI 状态错误 | 修复 read model/gateway/projection |
| Test infrastructure failure | fixture、端口、构建或环境故障 | 单独标记,不能计为产品通过 |
| External auth failure | 第三方服务不可用或账号缺失 | 保留本地链路证据并标记阻塞,不伪造授权成功 |
## 验收报告模板
```text
主目标:
当前阶段:
平台与应用版本:
场景:GB-xx
结果:PASS / FAIL / BLOCKED
真实链路:Electron -> preload/IPC -> App Server -> RuntimeCore -> read model -> GUI
身份:plugin / thread / turn / item / surface
legacy command hits0
mock fallback hits0
证据路径:
失败原因与修复:
未验证项:
current / deprecated / dead 变化:
完成度:xx%
```
## Release 阻塞条件
出现任一项即阻塞发布:
- P0 Gate B 场景失败或仅有 mock/Gate A 证据。
- Plugin identity 在 composer、runtime、read model 或 surface 之间断链。
- 任意 production path 命中旧 plugin worker、旧 manifest 或 renderer registry fallback。
- 安装/update 可能留下半安装态或覆盖上一可用版本。
- 卸载会误删共享凭证、用户文件或不可解释残留。
- macOS/Windows 任一支持平台没有安装到卸载的真实证据。
- 五语言缺失或窄窗口存在遮挡、变形和不可操作控件。
- 架构图确认、执行计划或 evidence summary 未落在仓库内。
@@ -0,0 +1,188 @@
# 旧 Plugin 文档提炼矩阵
状态:`historical-audit-complete`
更新时间:2026-08-04
## 目的
旧 Plugin 文档包含已经验证过的产品直觉、交互方案和问题记录,也包含与 Codex/current 主链冲突的历史实现假设。本文件把两者拆开,避免“保留文档”被误解为“保留旧事实源”。
原则只有一条:历史文档可以被阅读和提炼,v2 合同才可以驱动实现。
## 结论总览
| 旧内容 | 处理 | v2 落点 |
| -------------------------------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| Plugin 是分发、授权和能力组织的根对象 | 吸收 | [01-product-contract.md](./01-product-contract.md) |
| Claw 是对话、运行、审批和事实链主工作区 | 吸收 | [01-product-contract.md](./01-product-contract.md)、[03-architecture-and-command-contracts.md](./03-architecture-and-command-contracts.md) |
| Right Surface 是唯一物理右栏 | 吸收并收紧 | [04-app-center-and-claw-surfaces.md](./04-app-center-and-claw-surfaces.md) |
| 显式 `@plugin` 激活 | 吸收并改用稳定 plugin identity | [01-product-contract.md](./01-product-contract.md)、[04-app-center-and-claw-surfaces.md](./04-app-center-and-claw-surfaces.md) |
| 历史恢复 selected object、tabs、产物和插件上下文 | 吸收并改为 thread/read model projection | [03-architecture-and-command-contracts.md](./03-architecture-and-command-contracts.md)、[07-verification-contract.md](./07-verification-contract.md) |
| 安装、启用、授权、运行权限分离 | 吸收 | [01-product-contract.md](./01-product-contract.md)、[02-package-marketplace-installation.md](./02-package-marketplace-installation.md) |
| WebContentsView、Host policy、surface action 回流 | 吸收 | [03-architecture-and-command-contracts.md](./03-architecture-and-command-contracts.md)、Browser 路线图 |
| 插件自带 runtime/workbench、任意 renderer contract | 淘汰 | 不进入 v2;统一到 Skills/MCP/Hooks/Right Surface |
| LimeCore marketplace 作为 Lime consumer runtime 的强依赖 | 拆分 | consumer 先支持 bundled/repo/personal/configured/remote;发布平台另行治理 |
| 内容工厂、独立应用壳、发布后台同属 Plugin 根主线 | 拆分 | 内容工厂是插件样例,发布平台是独立范围,不阻塞 P0 consumer |
| 旧 `plugin.json``lime.plugin.package.v1` | 淘汰 | `.codex-plugin/plugin.json` |
| 旧 worker 和 renderer mock | 淘汰 | RuntimeCore、MCP、Skills 与 current read model |
## 按文档提炼
### `prd.md`:保留产品问题和用户路径
保留内容:
- 安装、授权、启用、显式激活、产物查看、历史恢复和卸载的完整用户路径。
- 用户需要知道来源、状态、权限、失败原因和恢复动作。
- 插件中心不能只是卡片列表,Claw 才是调用和继续工作的主场。
- 复杂结果应进入 Right Surface,不能把用户带到第二个任务系统。
修正内容:
- 旧 PRD 把发布、注册、租户 enablement 和消费 runtime 交织在一起;v2 把它们分成 consumer protocol、local install store 和独立 publishing scope。
- 旧 PRD 允许 workspace app/runtime 作为插件贡献;v2 只允许 Skills、MCP servers/apps、Hooks、assets 和受管 UI resource。
### `architecture.md`:保留边界图,替换事实源
保留内容:
- Host、App Server、Claw、Runtime、Right Surface、artifact/evidence 的分层思路。
- Surface 不直接调用 provider、filesystem、secret 或旧 desktop facade。
- action 必须通过统一 intent 回流 runtime,并产生可追踪 evidence。
- 历史恢复优先恢复对象和 surface 状态,不重跑危险 action。
替换内容:
- 旧图将 LimeCore marketplace 和本地 registry 合并视为主要事实源;v2 改为 App Server plugin domain 统一 projection。
- 旧图允许插件 workbench shell 成为能力边界;v2 规定 Right Surface 是宿主边界,插件提供 resource/descriptor,不拥有独立业务后端。
### `interface-contracts.md`:保留行为合同,废弃类型合同
保留内容:
- activation intent 的来源区分:user、route、restore、runtime。
- surface action 的确认、权限、错误和 evidence 回流要求。
- 历史恢复中的 primary/selected object 优先级。
- 稳定错误必须面向用户可恢复,而不是暴露 raw JSON。
废弃内容:
- 旧 `PluginManifest``activeWorkspaceAppId``historyRestore` 和 renderer/workbench contribution 类型不能直接变成 v2 schema。
- v2 只从 Codex manifest、App Server protocol 和现有 Right Surface contract 重新生成类型。
### `technical-baseline.md`:保留 Host 安全底线
可直接吸收的底线:
- Electron Host 管理 view、bounds、权限、错误边界和生命周期。
- 插件不能自行携带第二套 tab/history/permission/runtime 逻辑。
- `<webview>`、BrowserView、raw Tauri command、生产 mock 不作为新主路径。
- App Center 的安装/升级/卸载动作必须经过 current readiness 和 policy。
需要改写的地方:
- 旧 baseline 把插件独立 UI runtime 当成可选承载;v2 仅保留 MCP/App UI resource 和 Right Surface host。
- `articleDraft``imageGenerationSet` 等业务 artifact 不属于 Plugin 通用 manifest;它们应由对应领域 projection 定义。
### `prototype.md` / `prototype.html`:保留信息架构,不复制视觉实现
保留内容:
- App Center 首页、详情、能力列表、安装状态、composer 激活 strip、Right Surface 和历史恢复的布局关系。
- 详情页需要把 Overview、Skills、能力/权限、来源和状态分层展示。
- 用户需要在当前 Claw 上下文看到插件已激活,而不是被跳转到独立应用页。
不直接复用:
- 原型中的 `Renderer` tab、独立 workbench shell 和旧命令名称。
- 旧视觉尺寸、文案、品牌图标;实现必须遵守 Lime 当前主题和五语言合同。
### `history-product-workspace.md`:提炼恢复规则
v2 采用以下有效规则:
1. 先恢复 thread/read model,再恢复 plugin identity 和能力快照。
2. 再恢复 primary/selected object、surface tab 和布局状态。
3. 恢复失败显示可理解原因,不暴露内部 JSON 或绝对路径。
4. 不恢复未完成危险 action、不复活过期权限、不自动重跑外部副作用。
5. 历史恢复不得抢占用户当前正在使用的 thread 或 Right Surface tab。
旧的“工作台应用”命名和业务产物类型不作为 v2 通用协议字段。
### `user-operations-guide.md`:提炼运营诊断
保留内容:
- 安装、启用、禁用、卸载、授权和 blocked 状态要分别排查。
- 诊断需要关联 source、version/digest、policy、auth、runtime readiness 和 evidence。
- 卸载不应误删共享 connector 凭证;历史记录应保持可读。
- GUI 验收要覆盖安装态刷新、Right Surface 产物和历史入口。
替换内容:
- “客户端上报到 LimeCore 是唯一安装态事实”的旧说法改为 App Server installed projection;外部审计只消费受控摘要。
- 旧远端运行和发布授权不进入 v2 P0。
### `implementation-plan.md`:保留顺序,冻结进度记录
可复用顺序:
```text
contract
-> marketplace/manifest
-> explicit activation
-> Right Surface
-> history restore
-> evidence
```
需要舍弃的路线:
- 先做大量 renderer registry 和 plugin worker,再补 current runtime。
- 将内容工厂 dogfood、LimeCore 发布、租户灰度和客户端消费混成一个完成度。
- 用“骨架完成”替代真实 Electron/App Server/RuntimeCore 证据。
旧进度日志保留为历史审计,不再更新;新的进度只写入 `internal/exec-plans/` 和 v2 验收证据。
### `e2e-evidence.md` 与 JSON:保留证据结构,不能当 Gate B
可复用内容:
- 证据摘要、版本、scenario、安装态、激活态、Right Surface 和失败项应有固定字段。
- 证据需要同时记录客户端状态和服务端审计,避免只截 UI。
- 未覆盖项必须明确列出,不把 partial evidence 写成通过。
不能直接复用的原因:
- 旧证据围绕发布中心、LimeCore catalog 和 content-factory fixture,不能证明 v2 的真实 Electron/preload/App Server JSON-RPC/RuntimeCore 链。
- v2 Gate B 以 [07-verification-contract.md](./07-verification-contract.md) 为唯一验收合同,旧证据只能作为历史背景。
### `deverlop/*`:独立范围输入
发布中心文档仍有价值,尤其是签名、target 预检、审计、灰度、回滚和证据要求。但它们不应继续放大 Plugin consumer runtime 的范围。
处理方式:
- 当前保留原文作为历史范围输入。
- v2 只引用其安全和发布边界,不依赖其 API、数据库或 LimeCore endpoint。
- 未来如重新建设发布平台,应创建独立 roadmap/change,并重新确认 owner、协议和权限。
## 采纳后的不变量
以下不变量同时来自旧文档的有效经验和 v2 的 current 纠偏:
1. App Center 是发现、详情和生命周期入口,Claw 是调用和继续工作入口。
2. App Server 是 Plugin state/readiness 的事实源;Renderer 不扫描目录、不合并 registry。
3. Plugin 不拥有 agent loop、thread store、独立 worker 或第二右栏。
4. 所有高风险 action 经过统一权限、确认、事件和 evidence。
5. 历史恢复恢复状态和引用,不重放旧副作用。
6. 历史文档可以提供证据和上下文,但不覆盖 v2 protocol、current owner 和 Gate B 结果。
## 审计完成条件
- 根 README 能导航到历史参考和 v2 current。
- v2 文档能明确指出旧文档的吸收、替换和暂缓内容。
- 历史文档没有被标记为 current 实现输入。
- 新实现计划只引用 v2 文档、current owner 和新的 Gate B evidence。
- 旧文档保持可读,除非发现敏感数据、断链或明确错误需要单独修订。
+82
View File
@@ -0,0 +1,82 @@
# Plugin v2 路线图
状态:`active / macOS core Gate B passed`
更新时间:2026-08-05
## 主目标
Plugin v2 把 Lime 的插件能力收敛为一条与 Codex 对齐、由 App Server 持有事实、同时适配 Lime App Center 与 Claw 右侧工作区的产品链:
```text
Marketplace / local package
-> App Server plugin domain
-> installed plugin projection
-> Skills / MCP / Hooks activation
-> RuntimeCore turn context
-> App Center / Claw composer / Right Surface
```
本轮不是给旧应用中心换皮,也不是继续扩展 `lime.plugin.package.v1`。目标是直接替换旧包标准、旧前端 registry 合并和旧插件 worker UI 协议,建立唯一继续演进的 Plugin owner。
## 事实源声明
1. 包结构、marketplace、插件 ID、Skills/MCP/Hooks 组成与安装语义对齐 Codex。
2. Lime 的生产命令链固定为 `Electron Desktop Host -> App Server JSON-RPC -> RuntimeCore -> GUI`
3. App Server 是插件目录、安装状态、启用状态、授权状态和运行时装配的唯一事实源。
4. Renderer 只消费 projection;不得扫描插件目录、解析 manifest、合并多份 registry 或启动第二套业务后端。
5. Claw 右侧只承载 MCP/App UI、浏览器或声明式结果投影;插件执行仍归 RuntimeCore、MCP 与工具运行时。
## 文档导航
| 文档 | 职责 |
| -------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| [00-research-findings.md](./00-research-findings.md) | 官方文档、Codex 源码和本机 Desktop 安装包研究结论 |
| [01-product-contract.md](./01-product-contract.md) | 产品对象、用户流程、状态模型与范围边界 |
| [02-package-marketplace-installation.md](./02-package-marketplace-installation.md) | 包结构、manifest、marketplace、安装与存储合同 |
| [03-architecture-and-command-contracts.md](./03-architecture-and-command-contracts.md) | owner、JSON-RPC、运行时装配与事件投影 |
| [04-app-center-and-claw-surfaces.md](./04-app-center-and-claw-surfaces.md) | App Center、详情、composer mention 与 Claw 右侧交互 |
| [05-migration-and-cleanup.md](./05-migration-and-cleanup.md) | current/deprecated/dead 分类、旧实现退役与历史文档治理 |
| [06-implementation-plan.md](./06-implementation-plan.md) | 分阶段写集、退出条件和主链顺序 |
| [07-verification-contract.md](./07-verification-contract.md) | 单元、协议、集成、Gate A/B 与跨平台验收 |
| [08-legacy-synthesis.md](./08-legacy-synthesis.md) | 旧文档价值提炼、冲突决策与历史参考边界 |
## 非目标
- 不复刻 Codex/ChatGPT 的配色、圆角、字体或图标主题。
- 不把 Plugin、Skill、MCP、Agent App 继续做成相互独立的四套安装系统。
- 不在 Plugin v2 首阶段建设公共商业化发布平台、结算或审核后台。
- 不保留旧 manifest 双读双写、旧 worker fallback 或 renderer mock 生产降级。
- 不让 Electron main process 成为插件业务后端。
## 当前阶段与下一刀
当前阶段是 `V2-5 Claw 与 Right Surface / V2-7 release gate`
已完成的 current 主链包括:唯一 `.codex-plugin/plugin.json` manifest、App Server typed catalog/install/enable store、Plugin Skill/MCP activation、结构化 `plugin://` mention、MCP tool/elicitation、MCP App Right Surface 与 Renderer reload 恢复。2026-08-05 fresh Gate B 证据位于 `.lime/qc/gui-evidence/plugin-v2-current-electron-fixture/`
下一刀是把 Gate B 从受控 runtime 安装动作推进到真实 App Center 安装点击、Claw `@` picker 点击和卸载后历史可读,补齐 GB-01/07/13;随后补跨进程 cold restore、Windows 安装到卸载证据和剩余 Apps/Hooks/Browser/file surface。旧实现的物理删除仍须先列出精确目标并取得用户确认,旧版高价值文档继续保留为历史资料。
当前路线图完成度:`75%`。核心 macOS runtime/Right Surface 闭环已通过,但尚未达到跨平台 release gate。
## 完成定义
Plugin v2 只有同时满足以下条件才可标记完成:
- `.codex-plugin/plugin.json` 成为唯一插件 manifest。
- bundled、repo、personal、configured、workspace/remote marketplace 可由同一 list projection 表达。
- 安装、启用、授权、可用性和管理员策略是不同字段,不再压成一个布尔值。
- 插件 Skills、MCP servers/apps 与 Hooks 在新 thread/turn 中可追踪地装配。
- App Center 和 Claw 使用同一 plugin identity 与 installed projection。
- MCP/App UI 能进入 Claw Right Surface,且不存在第二套插件业务后端。
- 旧包标准、旧前端 registry 与旧 plugin worker UI 路径已删除并有回流守卫;旧路线图冻结为历史参考,不再形成并行事实源。
- macOS 与 Windows 至少各有一条安装、激活、运行和卸载证据。
- Gate B 证明真实 Electron、preload/IPC、App Server JSON-RPC、runtime/read model 与用户可见状态闭环。
## 参考优先级
1. `/Users/coso/Documents/dev/rust/codex`
2. OpenAI 官方 Plugins / Skills / MCP 文档
3. 本机 `/Applications/ChatGPT.app` 的真实安装包结构
4. Lime current App Server、MCP、Skills、Right Surface owner
5. `internal/roadmap/plugin/` v1 文件仅作为冻结的历史参考,不再是实现依据
+31 -30
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]]
name = "agent-protocol"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"schemars",
"serde",
@@ -30,7 +30,7 @@ dependencies = [
[[package]]
name = "agent-runtime"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"agent-protocol",
"anyhow",
@@ -181,7 +181,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "app-server"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"agent-protocol",
"agent-runtime",
@@ -221,6 +221,7 @@ dependencies = [
"runtime-core",
"rusqlite",
"scopeguard",
"semver",
"serde",
"serde_json",
"serde_yaml",
@@ -242,7 +243,7 @@ dependencies = [
[[package]]
name = "app-server-client"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"app-server-protocol",
"app-server-transport",
@@ -253,7 +254,7 @@ dependencies = [
[[package]]
name = "app-server-daemon"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"app-server-protocol",
"app-server-transport",
@@ -265,7 +266,7 @@ dependencies = [
[[package]]
name = "app-server-protocol"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"agent-protocol",
"schemars",
@@ -275,7 +276,7 @@ dependencies = [
[[package]]
name = "app-server-test-client"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"anyhow",
"app-server-client",
@@ -286,7 +287,7 @@ dependencies = [
[[package]]
name = "app-server-transport"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"app-server-protocol",
"axum",
@@ -1356,7 +1357,7 @@ dependencies = [
[[package]]
name = "document-preview"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"flate2",
"tempfile",
@@ -2619,7 +2620,7 @@ dependencies = [
[[package]]
name = "lime-agent"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"agent-protocol",
"agent-runtime",
@@ -2652,7 +2653,7 @@ dependencies = [
[[package]]
name = "lime-browser-runtime"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"chrono",
"futures",
@@ -2669,7 +2670,7 @@ dependencies = [
[[package]]
name = "lime-cli"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"clap",
"lime-core",
@@ -2681,7 +2682,7 @@ dependencies = [
[[package]]
name = "lime-config"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"async-trait",
"lime-core",
@@ -2697,7 +2698,7 @@ dependencies = [
[[package]]
name = "lime-core"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"agent-protocol",
"async-trait",
@@ -2753,7 +2754,7 @@ dependencies = [
[[package]]
name = "lime-gateway"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"aes",
"async-trait",
@@ -2781,7 +2782,7 @@ dependencies = [
[[package]]
name = "lime-infra"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"chrono",
"dashmap",
@@ -2803,7 +2804,7 @@ dependencies = [
[[package]]
name = "lime-knowledge"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"chrono",
"hex",
@@ -2816,7 +2817,7 @@ dependencies = [
[[package]]
name = "lime-mcp"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"async-trait",
"axum",
@@ -2842,7 +2843,7 @@ dependencies = [
[[package]]
name = "lime-media-runtime"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"axum",
"base64 0.22.1",
@@ -2861,7 +2862,7 @@ dependencies = [
[[package]]
name = "lime-processor"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"async-trait",
"lime-core",
@@ -2901,7 +2902,7 @@ dependencies = [
[[package]]
name = "lime-server"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"agent-protocol",
"async-stream",
@@ -2944,7 +2945,7 @@ dependencies = [
[[package]]
name = "lime-server-utils"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"axum",
"futures",
@@ -2959,7 +2960,7 @@ dependencies = [
[[package]]
name = "lime-services"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"anyhow",
"async-trait",
@@ -3002,7 +3003,7 @@ dependencies = [
[[package]]
name = "lime-skills"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -3023,7 +3024,7 @@ dependencies = [
[[package]]
name = "lime-websocket"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"axum",
"chrono",
@@ -3232,7 +3233,7 @@ dependencies = [
[[package]]
name = "model-provider"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"agent-protocol",
"anyhow",
@@ -3902,7 +3903,7 @@ checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec"
[[package]]
name = "patch-apply"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"tempfile",
"thiserror 1.0.69",
@@ -4661,7 +4662,7 @@ dependencies = [
[[package]]
name = "runtime-core"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"agent-protocol",
"app-server-protocol",
@@ -5409,7 +5410,7 @@ dependencies = [
[[package]]
name = "thread-store"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"agent-protocol",
"chrono",
@@ -5687,7 +5688,7 @@ dependencies = [
[[package]]
name = "tool-runtime"
version = "1.120.1"
version = "1.121.0"
dependencies = [
"agent-protocol",
"app-server-protocol",
+1 -1
View File
@@ -6,7 +6,7 @@ exclude = [
resolver = "2"
[workspace.package]
version = "1.120.1"
version = "1.121.0"
edition = "2021"
authors = ["coso"]
repository = "https://github.com/aiclientproxy/lime"
@@ -359,6 +359,10 @@ pub enum ThreadItemPayload {
call_id: String,
server_name: String,
tool_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
mcp_app_resource_uri: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
plugin_id: Option<String>,
#[serde(default)]
arguments: Vec<ToolArgument>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -1019,6 +1019,8 @@ mod tests {
server_name: "docs".to_string(),
tool_name: "search".to_string(),
runtime_tool_name: "mcp__docs__search".to_string(),
mcp_app_resource_uri: None,
plugin_id: None,
};
let result = await_mcp_call(&event_sender, "mcp-call-1", &route, call)
@@ -241,6 +241,8 @@ fn project_mcp_tool_item(
call_id,
server_name: route.server_name.clone(),
tool_name: route.tool_name.clone(),
mcp_app_resource_uri: route.mcp_app_resource_uri.clone(),
plugin_id: route.plugin_id.clone(),
arguments,
output,
},
@@ -277,6 +279,8 @@ mod tests {
server_name: "docs".to_string(),
tool_name: "search".to_string(),
runtime_tool_name: "docs__search".to_string(),
mcp_app_resource_uri: Some("ui://plugin/docs.html".to_string()),
plugin_id: Some("docs-plugin".to_string()),
}]);
let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
let emitter = CurrentTurnToolLifecycleEmitter::with_tool_routes(
@@ -309,8 +313,13 @@ mod tests {
ThreadItemPayload::McpToolCall {
ref server_name,
ref tool_name,
ref mcp_app_resource_uri,
ref plugin_id,
..
} if server_name == "docs" && tool_name == "search"
} if server_name == "docs"
&& tool_name == "search"
&& mcp_app_resource_uri.as_deref() == Some("ui://plugin/docs.html")
&& plugin_id.as_deref() == Some("docs-plugin")
));
}
+3 -1
View File
@@ -82,7 +82,9 @@ impl McpBridgeRuntimeRegistry {
McpConnectionProvenance::new(
snapshot.environment_id.clone(),
snapshot.auth_scopes.clone(),
),
)
.with_server_name(Some(snapshot.server_name.clone()))
.with_plugin_id(snapshot.plugin_id.clone()),
snapshot.supports_parallel_tool_calls,
client,
)
+13
View File
@@ -504,6 +504,19 @@ impl AgentRuntimeState {
runtime.shutdown().await;
}
pub async fn read_mcp_resource(
&self,
session_id: &str,
thread_id: &str,
server_name: &str,
uri: &str,
) -> Result<lime_mcp::McpResourceContent, String> {
self.mcp_runtime(session_id, thread_id)
.await?
.read_resource(server_name, uri)
.await
}
#[cfg(test)]
pub(crate) async fn mcp_runtime_count(&self) -> usize {
self.mcp_runtimes.read().await.len()
@@ -59,6 +59,17 @@ impl McpThreadRuntime {
self.server_specs.clone()
}
pub(crate) async fn read_resource(
&self,
server_name: &str,
uri: &str,
) -> Result<lime_mcp::McpResourceContent, String> {
self.manager
.read_resource(server_name, uri)
.await
.map_err(|error| error.to_string())
}
pub(crate) async fn start(&self) -> Result<(), String> {
let mut unavailable_servers = Vec::new();
let mut required_failures = Vec::new();
@@ -95,11 +106,18 @@ impl McpThreadRuntime {
required_failures.join("; ")
));
}
let snapshots = self
let mut snapshots = self
.manager
.bridge_snapshots()
.await
.map_err(|error| error.to_string())?;
for snapshot in &mut snapshots {
snapshot.plugin_id = self
.server_specs
.iter()
.find(|spec| spec.name == snapshot.server_name)
.and_then(|spec| spec.plugin_id.clone());
}
let bridge_count = self
.bridge_registry
.sync(&self.connections, snapshots)
@@ -17,6 +17,7 @@ fn stdio_server_spec(
) -> McpRuntimeServerSpec {
McpRuntimeServerSpec {
name: name.to_string(),
plugin_id: None,
config: McpServerConfig {
transport: McpServerTransport::Stdio {
command,
@@ -232,6 +233,111 @@ lines.on("line", (line) => {
state.clear_mcp_runtimes().await;
}
#[tokio::test]
async fn session_owned_mcp_runtime_reads_app_resource_with_meta() {
let Some(node) = node_binary() else {
return;
};
let temp_dir = tempfile::tempdir().expect("create runtime MCP app fixture directory");
let server_path = temp_dir.path().join("mcp-app-resource-server.mjs");
std::fs::write(
&server_path,
r#"
import readline from "node:readline";
const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
const send = (message) => process.stdout.write(JSON.stringify(message) + "\n");
lines.on("line", (line) => {
if (!line.trim()) return;
const message = JSON.parse(line);
if (message.method === "initialize") {
send({
jsonrpc: "2.0",
id: message.id,
result: {
protocolVersion: "2025-03-26",
capabilities: { resources: {}, tools: {} },
serverInfo: { name: "mcp-app-resource-fixture", version: "1.0.0" },
},
});
return;
}
if (message.method === "notifications/initialized") return;
if (message.method === "tools/list") {
send({ jsonrpc: "2.0", id: message.id, result: { tools: [] } });
return;
}
if (message.method === "resources/read") {
send({
jsonrpc: "2.0",
id: message.id,
result: {
contents: [{
uri: message.params?.uri,
mimeType: "text/html;profile=mcp-app",
text: "<!doctype html><main>MCP App</main>",
_meta: {
ui: { csp: { connectDomains: ["https://api.example.com"] } },
},
}],
},
});
}
});
"#,
)
.expect("write runtime MCP app fixture");
let state = AgentRuntimeState::new();
state
.ensure_mcp_runtime_generation(
"session-app".to_string(),
"thread-app".to_string(),
ElicitationRequestRouter::default(),
vec![stdio_server_spec(
"plugin__demo__server",
node,
vec![server_path.to_string_lossy().into_owned()],
true,
)],
)
.await
.expect("publish MCP App runtime generation");
let content = state
.read_mcp_resource(
"session-app",
"thread-app",
"plugin__demo__server",
"ui://demo/report.html",
)
.await
.expect("read MCP App resource from session runtime");
assert_eq!(content.uri, "ui://demo/report.html");
assert_eq!(
content.mime_type.as_deref(),
Some("text/html;profile=mcp-app")
);
assert_eq!(
content
.meta
.as_ref()
.and_then(|meta| meta.pointer("/ui/csp/connectDomains/0")),
Some(&serde_json::json!("https://api.example.com"))
);
assert!(state
.read_mcp_resource(
"session-app",
"thread-other",
"plugin__demo__server",
"ui://demo/report.html",
)
.await
.is_err());
state.clear_mcp_runtimes().await;
}
#[tokio::test]
async fn required_runtime_server_failure_keeps_the_previous_generation_published() {
let state = AgentRuntimeState::new();
@@ -3128,6 +3128,8 @@ mod tests {
.read_mcp_resource(McpResourceReadParams {
server: "filesystem".to_string(),
uri: "file:///workspace/README.md".to_string(),
session_id: None,
thread_id: None,
})
.expect("resource");
let resource_subscription = client
@@ -3256,6 +3258,8 @@ mod tests {
let read = client.read_mcp_resource(McpResourceReadParams {
server: " ".to_string(),
uri: "docs://readme".to_string(),
session_id: None,
thread_id: None,
});
let subscribe = client.subscribe_mcp_resource(McpResourceSubscribeParams {
server: "docs".to_string(),
File diff suppressed because it is too large Load Diff
@@ -750,6 +750,30 @@
"kind": "request",
"method": "modelSyncState/read"
},
{
"kind": "request",
"method": "plugin/enabled/set"
},
{
"kind": "request",
"method": "plugin/install"
},
{
"kind": "request",
"method": "plugin/installed"
},
{
"kind": "request",
"method": "plugin/list"
},
{
"kind": "request",
"method": "plugin/read"
},
{
"kind": "request",
"method": "plugin/uninstall"
},
{
"kind": "request",
"method": "pluginHostLifecycle/list"
@@ -2285,6 +2309,22 @@
"PermissionsRequestApprovalParams",
"PermissionsRequestApprovalResponse",
"PlanDeltaNotification",
"PluginCatalogCapability",
"PluginCatalogDetail",
"PluginCatalogEnabledSetParams",
"PluginCatalogEnabledSetResponse",
"PluginCatalogHook",
"PluginCatalogInstallParams",
"PluginCatalogInstallResponse",
"PluginCatalogInstalledParams",
"PluginCatalogListParams",
"PluginCatalogListResponse",
"PluginCatalogReadParams",
"PluginCatalogReadResponse",
"PluginCatalogSummary",
"PluginCatalogUiResource",
"PluginCatalogUninstallParams",
"PluginCatalogUninstallResponse",
"ReasoningEffortOption",
"ReasoningSummaryPartAddedNotification",
"ReasoningSummaryTextDeltaNotification",
@@ -5,6 +5,20 @@
"minLength": 1,
"type": "string"
},
"sessionId": {
"minLength": 1,
"type": [
"null",
"string"
]
},
"threadId": {
"minLength": 1,
"type": [
"null",
"string"
]
},
"uri": {
"minLength": 1,
"type": "string"
@@ -7,6 +7,7 @@
"string"
]
},
"meta": true,
"mime_type": {
"type": [
"null",
@@ -351,6 +351,99 @@
}
]
},
"PluginCatalogEnabledSetParams": {
"properties": {
"enabled": {
"type": "boolean"
},
"pluginId": {
"type": "string"
}
},
"required": [
"enabled",
"pluginId"
],
"type": "object"
},
"PluginCatalogInstallParams": {
"properties": {
"expectedDigest": {
"type": [
"null",
"string"
]
},
"marketplaceId": {
"type": [
"null",
"string"
]
},
"source": {
"type": [
"null",
"string"
]
},
"sourcePath": {
"type": "string"
}
},
"required": [
"sourcePath"
],
"type": "object"
},
"PluginCatalogInstalledParams": {
"type": "object"
},
"PluginCatalogListParams": {
"properties": {
"marketplacePaths": {
"default": [],
"items": {
"type": "string"
},
"type": "array"
},
"query": {
"type": [
"null",
"string"
]
},
"source": {
"type": [
"null",
"string"
]
}
},
"type": "object"
},
"PluginCatalogReadParams": {
"properties": {
"pluginId": {
"type": "string"
}
},
"required": [
"pluginId"
],
"type": "object"
},
"PluginCatalogUninstallParams": {
"properties": {
"pluginId": {
"type": "string"
}
},
"required": [
"pluginId"
],
"type": "object"
},
"RequestId": {
"anyOf": [
{
@@ -2519,6 +2612,126 @@
],
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/$defs/RequestId"
},
"method": {
"const": "plugin/list",
"type": "string"
},
"params": {
"$ref": "#/$defs/PluginCatalogListParams"
}
},
"required": [
"id",
"method",
"params"
],
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/$defs/RequestId"
},
"method": {
"const": "plugin/read",
"type": "string"
},
"params": {
"$ref": "#/$defs/PluginCatalogReadParams"
}
},
"required": [
"id",
"method",
"params"
],
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/$defs/RequestId"
},
"method": {
"const": "plugin/install",
"type": "string"
},
"params": {
"$ref": "#/$defs/PluginCatalogInstallParams"
}
},
"required": [
"id",
"method",
"params"
],
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/$defs/RequestId"
},
"method": {
"const": "plugin/uninstall",
"type": "string"
},
"params": {
"$ref": "#/$defs/PluginCatalogUninstallParams"
}
},
"required": [
"id",
"method",
"params"
],
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/$defs/RequestId"
},
"method": {
"const": "plugin/installed",
"type": "string"
},
"params": {
"$ref": "#/$defs/PluginCatalogInstalledParams"
}
},
"required": [
"id",
"method",
"params"
],
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/$defs/RequestId"
},
"method": {
"const": "plugin/enabled/set",
"type": "string"
},
"params": {
"$ref": "#/$defs/PluginCatalogEnabledSetParams"
}
},
"required": [
"id",
"method",
"params"
],
"type": "object"
},
{
"properties": {
"id": {
@@ -4,6 +4,12 @@
"artifact/write",
"media/read",
"model/list",
"plugin/enabled/set",
"plugin/install",
"plugin/installed",
"plugin/list",
"plugin/read",
"plugin/uninstall",
"thread/approveGuardianDeniedAction",
"thread/archive",
"thread/backgroundTerminals/clean",
@@ -0,0 +1,25 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"description": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"requiresAuth": {
"type": "boolean"
}
},
"required": [
"description",
"id",
"name",
"requiresAuth"
],
"title": "PluginCatalogCapability",
"type": "object"
}
@@ -0,0 +1,202 @@
{
"$defs": {
"PluginCatalogCapability": {
"properties": {
"description": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"requiresAuth": {
"type": "boolean"
}
},
"required": [
"description",
"id",
"name",
"requiresAuth"
],
"type": "object"
},
"PluginCatalogHook": {
"properties": {
"event": {
"type": "string"
},
"id": {
"type": "string"
}
},
"required": [
"event",
"id"
],
"type": "object"
},
"PluginCatalogSummary": {
"properties": {
"appsCount": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"authPolicy": {
"type": "string"
},
"availability": {
"type": "string"
},
"contentDigest": {
"type": "string"
},
"description": {
"type": "string"
},
"disabledReason": {
"type": [
"null",
"string"
]
},
"enabled": {
"type": "boolean"
},
"hooksCount": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"id": {
"type": "string"
},
"installPolicy": {
"type": "string"
},
"installed": {
"type": "boolean"
},
"localVersion": {
"type": [
"null",
"string"
]
},
"marketplaceId": {
"type": "string"
},
"mcpServersCount": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"name": {
"type": "string"
},
"skillsCount": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"source": {
"type": "string"
},
"sourceUri": {
"type": "string"
},
"version": {
"type": "string"
}
},
"required": [
"appsCount",
"authPolicy",
"availability",
"contentDigest",
"description",
"enabled",
"hooksCount",
"id",
"installPolicy",
"installed",
"marketplaceId",
"mcpServersCount",
"name",
"skillsCount",
"source",
"sourceUri",
"version"
],
"type": "object"
},
"PluginCatalogUiResource": {
"properties": {
"id": {
"type": "string"
},
"kind": {
"type": "string"
},
"resourceUri": {
"type": "string"
}
},
"required": [
"id",
"kind",
"resourceUri"
],
"type": "object"
}
},
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"apps": {
"items": {
"$ref": "#/$defs/PluginCatalogCapability"
},
"type": "array"
},
"hooks": {
"items": {
"$ref": "#/$defs/PluginCatalogHook"
},
"type": "array"
},
"mcpServers": {
"items": {
"$ref": "#/$defs/PluginCatalogCapability"
},
"type": "array"
},
"skills": {
"items": {
"$ref": "#/$defs/PluginCatalogCapability"
},
"type": "array"
},
"summary": {
"$ref": "#/$defs/PluginCatalogSummary"
},
"uiResources": {
"items": {
"$ref": "#/$defs/PluginCatalogUiResource"
},
"type": "array"
}
},
"required": [
"apps",
"hooks",
"mcpServers",
"skills",
"summary",
"uiResources"
],
"title": "PluginCatalogDetail",
"type": "object"
}
@@ -0,0 +1,17 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"enabled": {
"type": "boolean"
},
"pluginId": {
"type": "string"
}
},
"required": [
"enabled",
"pluginId"
],
"title": "PluginCatalogEnabledSetParams",
"type": "object"
}
@@ -0,0 +1,110 @@
{
"$defs": {
"PluginCatalogSummary": {
"properties": {
"appsCount": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"authPolicy": {
"type": "string"
},
"availability": {
"type": "string"
},
"contentDigest": {
"type": "string"
},
"description": {
"type": "string"
},
"disabledReason": {
"type": [
"null",
"string"
]
},
"enabled": {
"type": "boolean"
},
"hooksCount": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"id": {
"type": "string"
},
"installPolicy": {
"type": "string"
},
"installed": {
"type": "boolean"
},
"localVersion": {
"type": [
"null",
"string"
]
},
"marketplaceId": {
"type": "string"
},
"mcpServersCount": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"name": {
"type": "string"
},
"skillsCount": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"source": {
"type": "string"
},
"sourceUri": {
"type": "string"
},
"version": {
"type": "string"
}
},
"required": [
"appsCount",
"authPolicy",
"availability",
"contentDigest",
"description",
"enabled",
"hooksCount",
"id",
"installPolicy",
"installed",
"marketplaceId",
"mcpServersCount",
"name",
"skillsCount",
"source",
"sourceUri",
"version"
],
"type": "object"
}
},
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"plugin": {
"$ref": "#/$defs/PluginCatalogSummary"
}
},
"required": [
"plugin"
],
"title": "PluginCatalogEnabledSetResponse",
"type": "object"
}
@@ -0,0 +1,17 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"event": {
"type": "string"
},
"id": {
"type": "string"
}
},
"required": [
"event",
"id"
],
"title": "PluginCatalogHook",
"type": "object"
}
@@ -0,0 +1,31 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"expectedDigest": {
"type": [
"null",
"string"
]
},
"marketplaceId": {
"type": [
"null",
"string"
]
},
"source": {
"type": [
"null",
"string"
]
},
"sourcePath": {
"type": "string"
}
},
"required": [
"sourcePath"
],
"title": "PluginCatalogInstallParams",
"type": "object"
}
@@ -0,0 +1,110 @@
{
"$defs": {
"PluginCatalogSummary": {
"properties": {
"appsCount": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"authPolicy": {
"type": "string"
},
"availability": {
"type": "string"
},
"contentDigest": {
"type": "string"
},
"description": {
"type": "string"
},
"disabledReason": {
"type": [
"null",
"string"
]
},
"enabled": {
"type": "boolean"
},
"hooksCount": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"id": {
"type": "string"
},
"installPolicy": {
"type": "string"
},
"installed": {
"type": "boolean"
},
"localVersion": {
"type": [
"null",
"string"
]
},
"marketplaceId": {
"type": "string"
},
"mcpServersCount": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"name": {
"type": "string"
},
"skillsCount": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"source": {
"type": "string"
},
"sourceUri": {
"type": "string"
},
"version": {
"type": "string"
}
},
"required": [
"appsCount",
"authPolicy",
"availability",
"contentDigest",
"description",
"enabled",
"hooksCount",
"id",
"installPolicy",
"installed",
"marketplaceId",
"mcpServersCount",
"name",
"skillsCount",
"source",
"sourceUri",
"version"
],
"type": "object"
}
},
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"plugin": {
"$ref": "#/$defs/PluginCatalogSummary"
}
},
"required": [
"plugin"
],
"title": "PluginCatalogInstallResponse",
"type": "object"
}
@@ -0,0 +1,5 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "PluginCatalogInstalledParams",
"type": "object"
}
@@ -0,0 +1,26 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"marketplacePaths": {
"default": [],
"items": {
"type": "string"
},
"type": "array"
},
"query": {
"type": [
"null",
"string"
]
},
"source": {
"type": [
"null",
"string"
]
}
},
"title": "PluginCatalogListParams",
"type": "object"
}
@@ -0,0 +1,117 @@
{
"$defs": {
"PluginCatalogSummary": {
"properties": {
"appsCount": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"authPolicy": {
"type": "string"
},
"availability": {
"type": "string"
},
"contentDigest": {
"type": "string"
},
"description": {
"type": "string"
},
"disabledReason": {
"type": [
"null",
"string"
]
},
"enabled": {
"type": "boolean"
},
"hooksCount": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"id": {
"type": "string"
},
"installPolicy": {
"type": "string"
},
"installed": {
"type": "boolean"
},
"localVersion": {
"type": [
"null",
"string"
]
},
"marketplaceId": {
"type": "string"
},
"mcpServersCount": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"name": {
"type": "string"
},
"skillsCount": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"source": {
"type": "string"
},
"sourceUri": {
"type": "string"
},
"version": {
"type": "string"
}
},
"required": [
"appsCount",
"authPolicy",
"availability",
"contentDigest",
"description",
"enabled",
"hooksCount",
"id",
"installPolicy",
"installed",
"marketplaceId",
"mcpServersCount",
"name",
"skillsCount",
"source",
"sourceUri",
"version"
],
"type": "object"
}
},
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"generatedAt": {
"type": "string"
},
"plugins": {
"items": {
"$ref": "#/$defs/PluginCatalogSummary"
},
"type": "array"
}
},
"required": [
"generatedAt",
"plugins"
],
"title": "PluginCatalogListResponse",
"type": "object"
}
@@ -0,0 +1,13 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"pluginId": {
"type": "string"
}
},
"required": [
"pluginId"
],
"title": "PluginCatalogReadParams",
"type": "object"
}
@@ -0,0 +1,213 @@
{
"$defs": {
"PluginCatalogCapability": {
"properties": {
"description": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"requiresAuth": {
"type": "boolean"
}
},
"required": [
"description",
"id",
"name",
"requiresAuth"
],
"type": "object"
},
"PluginCatalogDetail": {
"properties": {
"apps": {
"items": {
"$ref": "#/$defs/PluginCatalogCapability"
},
"type": "array"
},
"hooks": {
"items": {
"$ref": "#/$defs/PluginCatalogHook"
},
"type": "array"
},
"mcpServers": {
"items": {
"$ref": "#/$defs/PluginCatalogCapability"
},
"type": "array"
},
"skills": {
"items": {
"$ref": "#/$defs/PluginCatalogCapability"
},
"type": "array"
},
"summary": {
"$ref": "#/$defs/PluginCatalogSummary"
},
"uiResources": {
"items": {
"$ref": "#/$defs/PluginCatalogUiResource"
},
"type": "array"
}
},
"required": [
"apps",
"hooks",
"mcpServers",
"skills",
"summary",
"uiResources"
],
"type": "object"
},
"PluginCatalogHook": {
"properties": {
"event": {
"type": "string"
},
"id": {
"type": "string"
}
},
"required": [
"event",
"id"
],
"type": "object"
},
"PluginCatalogSummary": {
"properties": {
"appsCount": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"authPolicy": {
"type": "string"
},
"availability": {
"type": "string"
},
"contentDigest": {
"type": "string"
},
"description": {
"type": "string"
},
"disabledReason": {
"type": [
"null",
"string"
]
},
"enabled": {
"type": "boolean"
},
"hooksCount": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"id": {
"type": "string"
},
"installPolicy": {
"type": "string"
},
"installed": {
"type": "boolean"
},
"localVersion": {
"type": [
"null",
"string"
]
},
"marketplaceId": {
"type": "string"
},
"mcpServersCount": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"name": {
"type": "string"
},
"skillsCount": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"source": {
"type": "string"
},
"sourceUri": {
"type": "string"
},
"version": {
"type": "string"
}
},
"required": [
"appsCount",
"authPolicy",
"availability",
"contentDigest",
"description",
"enabled",
"hooksCount",
"id",
"installPolicy",
"installed",
"marketplaceId",
"mcpServersCount",
"name",
"skillsCount",
"source",
"sourceUri",
"version"
],
"type": "object"
},
"PluginCatalogUiResource": {
"properties": {
"id": {
"type": "string"
},
"kind": {
"type": "string"
},
"resourceUri": {
"type": "string"
}
},
"required": [
"id",
"kind",
"resourceUri"
],
"type": "object"
}
},
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"plugin": {
"$ref": "#/$defs/PluginCatalogDetail"
}
},
"required": [
"plugin"
],
"title": "PluginCatalogReadResponse",
"type": "object"
}
@@ -0,0 +1,97 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"appsCount": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"authPolicy": {
"type": "string"
},
"availability": {
"type": "string"
},
"contentDigest": {
"type": "string"
},
"description": {
"type": "string"
},
"disabledReason": {
"type": [
"null",
"string"
]
},
"enabled": {
"type": "boolean"
},
"hooksCount": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"id": {
"type": "string"
},
"installPolicy": {
"type": "string"
},
"installed": {
"type": "boolean"
},
"localVersion": {
"type": [
"null",
"string"
]
},
"marketplaceId": {
"type": "string"
},
"mcpServersCount": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"name": {
"type": "string"
},
"skillsCount": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"source": {
"type": "string"
},
"sourceUri": {
"type": "string"
},
"version": {
"type": "string"
}
},
"required": [
"appsCount",
"authPolicy",
"availability",
"contentDigest",
"description",
"enabled",
"hooksCount",
"id",
"installPolicy",
"installed",
"marketplaceId",
"mcpServersCount",
"name",
"skillsCount",
"source",
"sourceUri",
"version"
],
"title": "PluginCatalogSummary",
"type": "object"
}
@@ -0,0 +1,21 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"id": {
"type": "string"
},
"kind": {
"type": "string"
},
"resourceUri": {
"type": "string"
}
},
"required": [
"id",
"kind",
"resourceUri"
],
"title": "PluginCatalogUiResource",
"type": "object"
}
@@ -0,0 +1,13 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"pluginId": {
"type": "string"
}
},
"required": [
"pluginId"
],
"title": "PluginCatalogUninstallParams",
"type": "object"
}
@@ -0,0 +1,17 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"pluginId": {
"type": "string"
},
"uninstalled": {
"type": "boolean"
}
},
"required": [
"pluginId",
"uninstalled"
],
"title": "PluginCatalogUninstallResponse",
"type": "object"
}
@@ -190,6 +190,12 @@ pub struct McpResourceReadParams {
pub server: String,
#[schemars(length(min = 1))]
pub uri: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(length(min = 1))]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(length(min = 1))]
pub thread_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
@@ -223,6 +229,8 @@ pub struct McpResourceReadResponse {
pub text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blob: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meta: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
@@ -9,7 +9,9 @@ use super::{
McpToolCallProgressNotification, MediaReadParams, MediaReadResponse, Method, ModelListParams,
ModelListUpdatedNotification, ModelReroutedNotification,
ModelSafetyBufferingUpdatedNotification, ModelVerificationNotification,
PermissionsRequestApprovalParams, PlanDeltaNotification, ReasoningSummaryPartAddedNotification,
PermissionsRequestApprovalParams, PlanDeltaNotification, PluginCatalogEnabledSetParams,
PluginCatalogInstallParams, PluginCatalogInstalledParams, PluginCatalogListParams,
PluginCatalogReadParams, PluginCatalogUninstallParams, ReasoningSummaryPartAddedNotification,
ReasoningSummaryTextDeltaNotification, ReasoningTextDeltaNotification,
ServerRequestResolvedNotification, SkillsChangedNotification,
ThreadApproveGuardianDeniedActionParams, ThreadApproveGuardianDeniedActionResponse,
@@ -236,6 +238,36 @@ pub enum ClientRequest {
id: RequestId,
params: ModelListParams,
},
#[serde(rename = "plugin/list")]
PluginList {
id: RequestId,
params: PluginCatalogListParams,
},
#[serde(rename = "plugin/read")]
PluginRead {
id: RequestId,
params: PluginCatalogReadParams,
},
#[serde(rename = "plugin/install")]
PluginInstall {
id: RequestId,
params: PluginCatalogInstallParams,
},
#[serde(rename = "plugin/uninstall")]
PluginUninstall {
id: RequestId,
params: PluginCatalogUninstallParams,
},
#[serde(rename = "plugin/installed")]
PluginInstalled {
id: RequestId,
params: PluginCatalogInstalledParams,
},
#[serde(rename = "plugin/enabled/set")]
PluginEnabledSet {
id: RequestId,
params: PluginCatalogEnabledSetParams,
},
#[serde(rename = "turn/start")]
TurnStart {
id: RequestId,
@@ -289,6 +321,12 @@ impl ClientRequest {
| Self::ArtifactWrite { id, .. }
| Self::MediaRead { id, .. }
| Self::ModelList { id, .. }
| Self::PluginList { id, .. }
| Self::PluginRead { id, .. }
| Self::PluginInstall { id, .. }
| Self::PluginUninstall { id, .. }
| Self::PluginInstalled { id, .. }
| Self::PluginEnabledSet { id, .. }
| Self::TurnStart { id, .. }
| Self::TurnSteer { id, .. }
| Self::TurnInterrupt { id, .. } => id,
@@ -334,6 +372,12 @@ impl ClientRequest {
Self::ArtifactWrite { .. } => Method::ArtifactWrite,
Self::MediaRead { .. } => Method::MediaRead,
Self::ModelList { .. } => Method::ModelList,
Self::PluginList { .. } => Method::PluginList,
Self::PluginRead { .. } => Method::PluginRead,
Self::PluginInstall { .. } => Method::PluginInstall,
Self::PluginUninstall { .. } => Method::PluginUninstall,
Self::PluginInstalled { .. } => Method::PluginInstalled,
Self::PluginEnabledSet { .. } => Method::PluginEnabledSet,
Self::TurnStart { .. } => Method::TurnStart,
Self::TurnSteer { .. } => Method::TurnSteer,
Self::TurnInterrupt { .. } => Method::TurnInterrupt,
@@ -35,6 +35,12 @@ pub const METHOD_THREAD_GOAL_CLEAR: &str = "thread/goal/clear";
pub const METHOD_ARTIFACT_WRITE: &str = "artifact/write";
pub const METHOD_MEDIA_READ: &str = "media/read";
pub const METHOD_MODEL_LIST: &str = "model/list";
pub const METHOD_PLUGIN_LIST: &str = "plugin/list";
pub const METHOD_PLUGIN_READ: &str = "plugin/read";
pub const METHOD_PLUGIN_INSTALL: &str = "plugin/install";
pub const METHOD_PLUGIN_UNINSTALL: &str = "plugin/uninstall";
pub const METHOD_PLUGIN_INSTALLED: &str = "plugin/installed";
pub const METHOD_PLUGIN_ENABLED_SET: &str = "plugin/enabled/set";
pub const METHOD_MODEL_LIST_UPDATED: &str = "model/list/updated";
pub const METHOD_TURN_START: &str = "turn/start";
pub const METHOD_TURN_STEER: &str = "turn/steer";
@@ -153,6 +159,18 @@ pub enum Method {
MediaRead,
#[serde(rename = "model/list")]
ModelList,
#[serde(rename = "plugin/list")]
PluginList,
#[serde(rename = "plugin/read")]
PluginRead,
#[serde(rename = "plugin/install")]
PluginInstall,
#[serde(rename = "plugin/uninstall")]
PluginUninstall,
#[serde(rename = "plugin/installed")]
PluginInstalled,
#[serde(rename = "plugin/enabled/set")]
PluginEnabledSet,
#[serde(rename = "turn/start")]
TurnStart,
#[serde(rename = "turn/steer")]
@@ -199,6 +217,12 @@ impl Method {
Self::ArtifactWrite => METHOD_ARTIFACT_WRITE,
Self::MediaRead => METHOD_MEDIA_READ,
Self::ModelList => METHOD_MODEL_LIST,
Self::PluginList => METHOD_PLUGIN_LIST,
Self::PluginRead => METHOD_PLUGIN_READ,
Self::PluginInstall => METHOD_PLUGIN_INSTALL,
Self::PluginUninstall => METHOD_PLUGIN_UNINSTALL,
Self::PluginInstalled => METHOD_PLUGIN_INSTALLED,
Self::PluginEnabledSet => METHOD_PLUGIN_ENABLED_SET,
Self::TurnStart => METHOD_TURN_START,
Self::TurnSteer => METHOD_TURN_STEER,
Self::TurnInterrupt => METHOD_TURN_INTERRUPT,
@@ -244,6 +268,12 @@ impl Method {
METHOD_ARTIFACT_WRITE => Some(Self::ArtifactWrite),
METHOD_MEDIA_READ => Some(Self::MediaRead),
METHOD_MODEL_LIST => Some(Self::ModelList),
METHOD_PLUGIN_LIST => Some(Self::PluginList),
METHOD_PLUGIN_READ => Some(Self::PluginRead),
METHOD_PLUGIN_INSTALL => Some(Self::PluginInstall),
METHOD_PLUGIN_UNINSTALL => Some(Self::PluginUninstall),
METHOD_PLUGIN_INSTALLED => Some(Self::PluginInstalled),
METHOD_PLUGIN_ENABLED_SET => Some(Self::PluginEnabledSet),
METHOD_TURN_START => Some(Self::TurnStart),
METHOD_TURN_STEER => Some(Self::TurnSteer),
METHOD_TURN_INTERRUPT => Some(Self::TurnInterrupt),
@@ -286,6 +316,12 @@ pub const METHODS: &[&str] = &[
METHOD_ARTIFACT_WRITE,
METHOD_MEDIA_READ,
METHOD_MODEL_LIST,
METHOD_PLUGIN_LIST,
METHOD_PLUGIN_READ,
METHOD_PLUGIN_INSTALL,
METHOD_PLUGIN_UNINSTALL,
METHOD_PLUGIN_INSTALLED,
METHOD_PLUGIN_ENABLED_SET,
METHOD_TURN_START,
METHOD_TURN_STEER,
METHOD_TURN_INTERRUPT,
@@ -11,6 +11,7 @@ mod media;
mod methods;
mod model;
mod notification;
mod plugin;
mod request_permissions;
mod schema_types;
mod thread;
@@ -31,6 +32,7 @@ pub use media::*;
pub use methods::*;
pub use model::*;
pub use notification::*;
pub use plugin::*;
pub use request_permissions::*;
pub use schema_types::*;
pub use thread::*;
@@ -0,0 +1,141 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct PluginCatalogListParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub query: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default)]
pub marketplace_paths: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct PluginCatalogListResponse {
pub plugins: Vec<PluginCatalogSummary>,
pub generated_at: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct PluginCatalogInstalledParams {}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct PluginCatalogEnabledSetParams {
pub plugin_id: String,
pub enabled: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct PluginCatalogEnabledSetResponse {
pub plugin: PluginCatalogSummary,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct PluginCatalogSummary {
pub id: String,
pub name: String,
pub version: String,
pub marketplace_id: String,
pub content_digest: String,
pub description: String,
pub source: String,
pub source_uri: String,
pub installed: bool,
pub enabled: bool,
pub install_policy: String,
pub auth_policy: String,
pub availability: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disabled_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local_version: Option<String>,
pub skills_count: u32,
pub mcp_servers_count: u32,
pub apps_count: u32,
pub hooks_count: u32,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct PluginCatalogReadParams {
pub plugin_id: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct PluginCatalogReadResponse {
pub plugin: PluginCatalogDetail,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct PluginCatalogDetail {
pub summary: PluginCatalogSummary,
pub skills: Vec<PluginCatalogCapability>,
pub mcp_servers: Vec<PluginCatalogCapability>,
pub apps: Vec<PluginCatalogCapability>,
pub hooks: Vec<PluginCatalogHook>,
pub ui_resources: Vec<PluginCatalogUiResource>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct PluginCatalogCapability {
pub id: String,
pub name: String,
pub description: String,
pub requires_auth: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct PluginCatalogHook {
pub id: String,
pub event: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct PluginCatalogUiResource {
pub id: String,
pub resource_uri: String,
pub kind: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct PluginCatalogInstallParams {
pub source_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub marketplace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_digest: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct PluginCatalogInstallResponse {
pub plugin: PluginCatalogSummary,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct PluginCatalogUninstallParams {
pub plugin_id: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct PluginCatalogUninstallResponse {
pub plugin_id: String,
pub uninstalled: bool,
}
@@ -70,6 +70,22 @@ pub const V2_SCHEMA_TYPE_NAMES: &[&str] = &[
"Model",
"ModelListResponse",
"ModelListUpdatedNotification",
"PluginCatalogListParams",
"PluginCatalogInstalledParams",
"PluginCatalogEnabledSetParams",
"PluginCatalogEnabledSetResponse",
"PluginCatalogListResponse",
"PluginCatalogSummary",
"PluginCatalogReadParams",
"PluginCatalogReadResponse",
"PluginCatalogDetail",
"PluginCatalogCapability",
"PluginCatalogHook",
"PluginCatalogUiResource",
"PluginCatalogInstallParams",
"PluginCatalogInstallResponse",
"PluginCatalogUninstallParams",
"PluginCatalogUninstallResponse",
"ModelRerouteReason",
"ModelReroutedNotification",
"ModelVerification",
@@ -823,6 +823,22 @@ pub(super) fn v2_schemas() -> Vec<GeneratedJsonSchema> {
typed_schema::<v2::Model>("Model"),
typed_schema::<v2::ModelListResponse>("ModelListResponse"),
typed_schema::<v2::ModelListUpdatedNotification>("ModelListUpdatedNotification"),
typed_schema::<v2::PluginCatalogListParams>("PluginCatalogListParams"),
typed_schema::<v2::PluginCatalogInstalledParams>("PluginCatalogInstalledParams"),
typed_schema::<v2::PluginCatalogEnabledSetParams>("PluginCatalogEnabledSetParams"),
typed_schema::<v2::PluginCatalogEnabledSetResponse>("PluginCatalogEnabledSetResponse"),
typed_schema::<v2::PluginCatalogListResponse>("PluginCatalogListResponse"),
typed_schema::<v2::PluginCatalogSummary>("PluginCatalogSummary"),
typed_schema::<v2::PluginCatalogReadParams>("PluginCatalogReadParams"),
typed_schema::<v2::PluginCatalogReadResponse>("PluginCatalogReadResponse"),
typed_schema::<v2::PluginCatalogDetail>("PluginCatalogDetail"),
typed_schema::<v2::PluginCatalogCapability>("PluginCatalogCapability"),
typed_schema::<v2::PluginCatalogHook>("PluginCatalogHook"),
typed_schema::<v2::PluginCatalogUiResource>("PluginCatalogUiResource"),
typed_schema::<v2::PluginCatalogInstallParams>("PluginCatalogInstallParams"),
typed_schema::<v2::PluginCatalogInstallResponse>("PluginCatalogInstallResponse"),
typed_schema::<v2::PluginCatalogUninstallParams>("PluginCatalogUninstallParams"),
typed_schema::<v2::PluginCatalogUninstallResponse>("PluginCatalogUninstallResponse"),
typed_schema::<v2::ModelRerouteReason>("ModelRerouteReason"),
typed_schema::<v2::ModelReroutedNotification>("ModelReroutedNotification"),
typed_schema::<v2::ModelVerification>("ModelVerification"),
+1
View File
@@ -54,6 +54,7 @@ scopeguard.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
semver.workspace = true
sha2.workspace = true
portable-pty.workspace = true
patch-apply.workspace = true
@@ -33,6 +33,7 @@ mod mcp;
mod media_tasks;
mod model_projection;
mod model_providers;
pub(super) mod plugin_catalog;
mod plugins;
mod project_materials;
mod right_surface;
@@ -87,7 +87,7 @@ impl McpAppDataSource for LocalAppDataSource {
async fn list_mcp_runtime_server_specs(
&self,
) -> Result<Vec<lime_mcp::McpRuntimeServerSpec>, RuntimeCoreError> {
mcp::list_mcp_runtime_server_specs(&self.db)
mcp::list_mcp_runtime_server_specs(&self.db, &self.plugin_data_root)
}
async fn list_mcp_tools_for_context(
@@ -1,4 +1,10 @@
use super::super::*;
use app_server_protocol::protocol::v2::{
PluginCatalogEnabledSetParams, PluginCatalogEnabledSetResponse, PluginCatalogInstallParams,
PluginCatalogInstallResponse, PluginCatalogInstalledParams, PluginCatalogListParams,
PluginCatalogListResponse, PluginCatalogReadParams, PluginCatalogReadResponse,
PluginCatalogUninstallParams, PluginCatalogUninstallResponse,
};
use async_trait::async_trait;
#[async_trait]
@@ -7,6 +13,61 @@ impl PluginDataSource for LocalAppDataSource {
Ok(self.plugin_data_root.clone())
}
async fn list_plugin_catalog(
&self,
params: PluginCatalogListParams,
) -> Result<PluginCatalogListResponse, RuntimeCoreError> {
crate::local_data_source::plugin_catalog::list(&self.plugin_data_root, params)
.map_err(data_error)
}
async fn read_plugin_catalog(
&self,
params: PluginCatalogReadParams,
) -> Result<PluginCatalogReadResponse, RuntimeCoreError> {
crate::local_data_source::plugin_catalog::read(&self.plugin_data_root, params)
.map_err(data_error)
}
async fn install_plugin_catalog(
&self,
params: PluginCatalogInstallParams,
) -> Result<PluginCatalogInstallResponse, RuntimeCoreError> {
crate::local_data_source::plugin_catalog::install(&self.plugin_data_root, params)
.map_err(data_error)
}
async fn uninstall_plugin_catalog(
&self,
params: PluginCatalogUninstallParams,
) -> Result<PluginCatalogUninstallResponse, RuntimeCoreError> {
crate::local_data_source::plugin_catalog::uninstall(&self.plugin_data_root, params)
.map_err(data_error)
}
async fn list_plugin_catalog_installed(
&self,
params: PluginCatalogInstalledParams,
) -> Result<PluginCatalogListResponse, RuntimeCoreError> {
crate::local_data_source::plugin_catalog::installed(&self.plugin_data_root, params)
.map_err(data_error)
}
async fn list_plugin_catalog_activations(&self) -> Result<Vec<Value>, RuntimeCoreError> {
crate::local_data_source::plugin_catalog::enabled_activation_descriptors(
&self.plugin_data_root,
)
.map_err(data_error)
}
async fn set_plugin_catalog_enabled(
&self,
params: PluginCatalogEnabledSetParams,
) -> Result<PluginCatalogEnabledSetResponse, RuntimeCoreError> {
crate::local_data_source::plugin_catalog::set_enabled(&self.plugin_data_root, params)
.map_err(data_error)
}
async fn list_plugin_installed(&self) -> Result<PluginInstalledListResponse, RuntimeCoreError> {
plugins::list_plugin_installed_state(&self.plugin_data_root).map_err(data_error)
}
@@ -38,6 +38,8 @@ use lime_mcp::McpServerConfig;
use lime_services::mcp_service::McpService;
use serde_json::json;
use serde_json::Value;
use std::collections::HashSet;
use std::path::Path;
pub(crate) fn list_mcp_servers(
db: &DbConnection,
@@ -196,8 +198,9 @@ pub(crate) async fn list_mcp_tools(
pub(crate) fn list_mcp_runtime_server_specs(
db: &DbConnection,
plugin_data_root: &Path,
) -> Result<Vec<lime_mcp::McpRuntimeServerSpec>, RuntimeCoreError> {
Ok(McpService::get_all(db)
let mut specs = McpService::get_all(db)
.map_err(data_error)?
.into_iter()
.filter(|server| server.enabled_lime)
@@ -205,10 +208,34 @@ pub(crate) fn list_mcp_runtime_server_specs(
let config = parse_mcp_server_config(&server.server_config);
config.enabled.then(|| lime_mcp::McpRuntimeServerSpec {
name: server.name,
plugin_id: None,
config,
})
})
.collect::<Vec<_>>())
.collect::<Vec<_>>();
let mut names = specs
.iter()
.map(|spec| spec.name.clone())
.collect::<HashSet<_>>();
match super::plugin_catalog::list_plugin_mcp_runtime_server_specs(plugin_data_root) {
Ok(plugin_specs) => {
for spec in plugin_specs {
if !names.insert(spec.name.clone()) {
tracing::warn!(
server_name = %spec.name,
"跳过与现有 MCP server 同名的 Plugin MCP server"
);
continue;
}
specs.push(spec);
}
}
Err(error) => {
tracing::warn!(%error, "Plugin MCP runtime 配置读取失败,跳过 Plugin MCP servers")
}
}
specs.sort_by(|left, right| left.name.cmp(&right.name));
Ok(specs)
}
pub(crate) async fn list_mcp_tools_for_context(
@@ -318,6 +345,7 @@ pub(crate) async fn read_mcp_resource(
mime_type: result.mime_type,
text: result.text,
blob: result.blob,
meta: result.meta,
})
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,311 @@
use super::*;
use tempfile::TempDir;
fn fixture(root: &Path) {
fs::create_dir_all(root.join(".codex-plugin")).unwrap();
fs::create_dir_all(root.join("skills/example")).unwrap();
fs::write(
root.join("skills/example/SKILL.md"),
"---\nname: example\n---\n",
)
.unwrap();
fs::write(
root.join(".codex-plugin/plugin.json"),
r#"{
"name": "example-plugin",
"version": "1.2.3",
"description": "Example",
"skills": "./skills/",
"interface": {"displayName": "Example Plugin"}
}"#,
)
.unwrap();
}
fn install_params(source: &Path) -> PluginCatalogInstallParams {
PluginCatalogInstallParams {
source_path: source.to_string_lossy().into_owned(),
marketplace_id: Some("test-marketplace".to_string()),
source: Some("repo".to_string()),
expected_digest: None,
}
}
#[test]
fn installs_codex_manifest_into_v2_store() {
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
fixture(&source);
let data_root = temp.path().join("data");
let response = install(&data_root, install_params(&source)).unwrap();
assert_eq!(response.plugin.id, "example-plugin");
assert_eq!(response.plugin.marketplace_id, "test-marketplace");
assert!(response.plugin.content_digest.starts_with("sha256:"));
let detail = read(
&data_root,
PluginCatalogReadParams {
plugin_id: "example-plugin".to_string(),
},
)
.unwrap();
assert_eq!(detail.plugin.skills.len(), 1);
let activations = enabled_activation_descriptors(&data_root).unwrap();
assert_eq!(activations.len(), 1);
assert_eq!(activations[0]["pluginId"], "example-plugin");
assert_eq!(
activations[0]["runtimeCapabilities"]["skills"][0]["id"],
"example"
);
assert_eq!(
installed(&data_root, PluginCatalogInstalledParams {})
.unwrap()
.plugins
.len(),
1
);
let disabled = set_enabled(
&data_root,
app_server_protocol::protocol::v2::PluginCatalogEnabledSetParams {
plugin_id: "example-plugin".to_string(),
enabled: false,
},
)
.unwrap();
assert!(!disabled.plugin.enabled);
assert!(enabled_activation_descriptors(&data_root)
.unwrap()
.is_empty());
assert!(
uninstall(
&data_root,
PluginCatalogUninstallParams {
plugin_id: "example-plugin".to_string()
}
)
.unwrap()
.uninstalled
);
}
#[test]
fn repeated_install_is_idempotent_but_same_version_digest_conflict_fails() {
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
fixture(&source);
let data_root = temp.path().join("data");
let first = install(&data_root, install_params(&source)).unwrap();
let second = install(&data_root, install_params(&source)).unwrap();
assert_eq!(first.plugin.content_digest, second.plugin.content_digest);
fs::write(source.join("skills/example/SKILL.md"), "changed").unwrap();
let error = install(&data_root, install_params(&source)).unwrap_err();
assert!(error.contains("content digest 不同"));
}
#[test]
fn expected_digest_and_semver_fail_closed() {
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
fixture(&source);
let data_root = temp.path().join("data");
let mut params = install_params(&source);
params.expected_digest = Some("sha256:wrong".to_string());
assert!(install(&data_root, params)
.unwrap_err()
.contains("digest 不一致"));
fs::write(
source.join(".codex-plugin/plugin.json"),
r#"{"name":"example-plugin","version":"1.2"}"#,
)
.unwrap();
assert!(read_manifest(&source).unwrap_err().contains("semver"));
}
#[test]
fn rejects_legacy_manifest_and_parent_paths() {
let temp = TempDir::new().unwrap();
let root = temp.path().join("source");
fs::create_dir_all(root.join(".codex-plugin")).unwrap();
fs::write(
root.join(".codex-plugin/plugin.json"),
r#"{"name":"example-plugin","version":"1.0.0","schemaVersion":"lime.plugin.package.v1"}"#,
)
.unwrap();
assert!(read_manifest(&root).is_err());
assert!(resource_path(&root, "../outside").is_err());
}
#[test]
fn resolves_codex_marketplace_sources_from_marketplace_root() {
let temp = TempDir::new().unwrap();
let marketplace_root = temp.path().join("openai-bundled");
let package_root = marketplace_root.join("plugins/browser");
fixture(&package_root);
let marketplace_path = marketplace_root.join(".agents/plugins/marketplace.json");
fs::create_dir_all(marketplace_path.parent().unwrap()).unwrap();
fs::write(
&marketplace_path,
r#"{
"name": "openai-bundled",
"plugins": [{
"name": "example-plugin",
"source": {"source": "local", "path": "./plugins/browser"}
}]
}"#,
)
.unwrap();
let response = list(
&temp.path().join("data"),
PluginCatalogListParams {
marketplace_paths: vec![marketplace_path.to_string_lossy().into_owned()],
..Default::default()
},
)
.unwrap();
assert_eq!(response.plugins.len(), 1);
assert_eq!(response.plugins[0].id, "example-plugin");
assert_eq!(response.plugins[0].marketplace_id, "openai-bundled");
}
#[test]
fn rejects_marketplace_local_source_outside_marketplace_root() {
let temp = TempDir::new().unwrap();
let marketplace_root = temp.path().join("marketplace");
let outside = temp.path().join("outside");
fixture(&outside);
let marketplace_path = marketplace_root.join(".agents/plugins/marketplace.json");
fs::create_dir_all(marketplace_path.parent().unwrap()).unwrap();
fs::write(
&marketplace_path,
r#"{
"name": "invalid-local-source",
"plugins": [{
"name": "example-plugin",
"source": {"source": "local", "path": "../outside"}
}]
}"#,
)
.unwrap();
let error = discover_package_roots(&marketplace_path).unwrap_err();
assert!(error.contains("local source 越界"), "{error}");
}
#[test]
fn loads_default_mcp_file_and_resolves_cwd_to_installed_package_root() {
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
fixture(&source);
fs::create_dir_all(source.join("scripts")).unwrap();
fs::write(
source.join(".mcp.json"),
r#"{
"mcpServers": {
"demo": {"command": "demo-mcp", "cwd": "scripts"}
}
}"#,
)
.unwrap();
let data_root = temp.path().join("data");
install(&data_root, install_params(&source)).unwrap();
let specs = list_plugin_mcp_runtime_server_specs(&data_root).unwrap();
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].name, "plugin__example-plugin__demo");
assert_eq!(specs[0].plugin_id.as_deref(), Some("example-plugin"));
assert_eq!(
specs[0].config.sanitized_cwd(),
Some(fs::canonicalize(data_root.join("v2/packages/example-plugin/1.2.3/scripts")).unwrap())
);
let activation = enabled_activation_descriptors(&data_root).unwrap();
assert_eq!(
activation[0]["runtimeCapabilities"]["mcpServers"][0]["runtimeName"],
"plugin__example-plugin__demo"
);
}
#[test]
fn loads_inline_and_manifest_path_mcp_declarations() {
let temp = TempDir::new().unwrap();
let inline_source = temp.path().join("inline");
fixture(&inline_source);
fs::write(
inline_source.join(".codex-plugin/plugin.json"),
r#"{
"name": "inline-plugin",
"version": "1.0.0",
"mcpServers": {"inline": {"command": "inline-mcp"}}
}"#,
)
.unwrap();
let file_source = temp.path().join("file");
fixture(&file_source);
fs::write(
file_source.join(".codex-plugin/plugin.json"),
r#"{
"name": "file-plugin",
"version": "1.0.0",
"mcpServers": "./config/mcp.json"
}"#,
)
.unwrap();
fs::create_dir_all(file_source.join("config")).unwrap();
fs::write(
file_source.join("config/mcp.json"),
r#"{"mcpServers":{"file":{"command":"file-mcp"}}}"#,
)
.unwrap();
let data_root = temp.path().join("data");
install(&data_root, install_params(&inline_source)).unwrap();
install(&data_root, install_params(&file_source)).unwrap();
let specs = list_plugin_mcp_runtime_server_specs(&data_root).unwrap();
assert_eq!(
specs
.iter()
.map(|spec| spec.name.as_str())
.collect::<Vec<_>>(),
vec!["plugin__file-plugin__file", "plugin__inline-plugin__inline"]
);
}
#[test]
fn disabled_plugin_and_invalid_mcp_siblings_are_fail_closed() {
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
fixture(&source);
fs::create_dir_all(temp.path().join("outside")).unwrap();
fs::write(
source.join(".mcp.json"),
r#"{
"mcpServers": {
"valid": {"command": "valid-mcp"},
"invalid": {"url": "ftp://not-supported"},
"escape": {"command": "escape-mcp", "cwd": "../outside"}
}
}"#,
)
.unwrap();
let data_root = temp.path().join("data");
install(&data_root, install_params(&source)).unwrap();
let specs = list_plugin_mcp_runtime_server_specs(&data_root).unwrap();
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].name, "plugin__example-plugin__valid");
set_enabled(
&data_root,
app_server_protocol::protocol::v2::PluginCatalogEnabledSetParams {
plugin_id: "example-plugin".to_string(),
enabled: false,
},
)
.unwrap();
assert!(list_plugin_mcp_runtime_server_specs(&data_root)
.unwrap()
.is_empty());
}
@@ -502,6 +502,24 @@ impl RequestProcessor {
METHOD_BROWSER_SESSION_ACTION_EXECUTE => self
.handle_browser_session_action_execute_impl(params)
.boxed(),
app_server_protocol::protocol::v2::METHOD_PLUGIN_LIST => {
self.handle_plugin_catalog_list_v2_impl(params).boxed()
}
app_server_protocol::protocol::v2::METHOD_PLUGIN_READ => {
self.handle_plugin_catalog_read_v2_impl(params).boxed()
}
app_server_protocol::protocol::v2::METHOD_PLUGIN_INSTALL => {
self.handle_plugin_catalog_install_v2_impl(params).boxed()
}
app_server_protocol::protocol::v2::METHOD_PLUGIN_UNINSTALL => {
self.handle_plugin_catalog_uninstall_v2_impl(params).boxed()
}
app_server_protocol::protocol::v2::METHOD_PLUGIN_INSTALLED => {
self.handle_plugin_catalog_installed_v2_impl(params).boxed()
}
app_server_protocol::protocol::v2::METHOD_PLUGIN_ENABLED_SET => self
.handle_plugin_catalog_enabled_set_v2_impl(params)
.boxed(),
METHOD_PLUGIN_LOCAL_PACKAGE_INSPECT => self
.handle_plugin_local_package_inspect_impl(params)
.boxed(),
@@ -78,6 +78,14 @@ pub(super) fn into_parts(
ClientRequest::ArtifactWrite { id, params } => parts(id, Method::ArtifactWrite, params),
ClientRequest::MediaRead { id, params } => parts(id, Method::MediaRead, params),
ClientRequest::ModelList { id, params } => parts(id, Method::ModelList, params),
ClientRequest::PluginList { id, params } => parts(id, Method::PluginList, params),
ClientRequest::PluginRead { id, params } => parts(id, Method::PluginRead, params),
ClientRequest::PluginInstall { id, params } => parts(id, Method::PluginInstall, params),
ClientRequest::PluginUninstall { id, params } => parts(id, Method::PluginUninstall, params),
ClientRequest::PluginInstalled { id, params } => parts(id, Method::PluginInstalled, params),
ClientRequest::PluginEnabledSet { id, params } => {
parts(id, Method::PluginEnabledSet, params)
}
ClientRequest::ThreadSettingsUpdate { id, params } => {
parts(id, Method::ThreadSettingsUpdate, params)
}
@@ -123,7 +131,9 @@ fn parts(
mod tests {
use super::*;
use app_server_protocol::protocol::v2::{
METHOD_MEDIA_READ, METHOD_THREAD_READ, METHOD_THREAD_RESUME, METHOD_TURN_INTERRUPT,
METHOD_MEDIA_READ, METHOD_PLUGIN_ENABLED_SET, METHOD_PLUGIN_INSTALL,
METHOD_PLUGIN_INSTALLED, METHOD_PLUGIN_LIST, METHOD_PLUGIN_READ, METHOD_PLUGIN_UNINSTALL,
METHOD_THREAD_READ, METHOD_THREAD_RESUME, METHOD_TURN_INTERRUPT,
};
use app_server_protocol::RequestId;
use serde_json::json;
@@ -212,4 +222,28 @@ mod tests {
let (_, method, _) = into_parts(request).expect("lower request");
assert_eq!(method, METHOD_TURN_INTERRUPT);
}
#[test]
fn plugin_v2_methods_lower_through_typed_ingress() {
for (method, params) in [
(METHOD_PLUGIN_LIST, json!({})),
(METHOD_PLUGIN_READ, json!({ "pluginId": "demo" })),
(
METHOD_PLUGIN_INSTALL,
json!({ "sourcePath": "/tmp/demo-plugin" }),
),
(METHOD_PLUGIN_UNINSTALL, json!({ "pluginId": "demo" })),
(METHOD_PLUGIN_INSTALLED, json!({})),
(
METHOD_PLUGIN_ENABLED_SET,
json!({ "pluginId": "demo", "enabled": true }),
),
] {
let request = decode(&request(method, params))
.expect("decode plugin request")
.expect("plugin method is v2");
let (_, lowered_method, _) = into_parts(request).expect("lower plugin request");
assert_eq!(lowered_method, method);
}
}
}
@@ -1,6 +1,10 @@
//! plugin domain handlers for the App Server processor.
use super::{dispatch_result, parse_params, to_jsonrpc_error, RequestProcessor, RpcDispatch};
use app_server_protocol::protocol::v2::{
PluginCatalogEnabledSetParams, PluginCatalogInstallParams, PluginCatalogInstalledParams,
PluginCatalogListParams, PluginCatalogReadParams, PluginCatalogUninstallParams,
};
use app_server_protocol::{
JsonRpcError, PluginFetchCloudPackageParams, PluginInstalledDisabledSetParams,
PluginInstalledSaveParams, PluginLocalPackageExportParams, PluginLocalPackageInspectParams,
@@ -9,6 +13,90 @@ use app_server_protocol::{
};
impl RequestProcessor {
pub(super) async fn handle_plugin_catalog_list_v2_impl(
&self,
params: Option<serde_json::Value>,
) -> Result<RpcDispatch, JsonRpcError> {
self.ensure_initialized()?;
let params: PluginCatalogListParams = parse_params(params)?;
let response = self
.runtime
.list_plugin_catalog(params)
.await
.map_err(to_jsonrpc_error)?;
dispatch_result(response)
}
pub(super) async fn handle_plugin_catalog_read_v2_impl(
&self,
params: Option<serde_json::Value>,
) -> Result<RpcDispatch, JsonRpcError> {
self.ensure_initialized()?;
let params: PluginCatalogReadParams = parse_params(params)?;
let response = self
.runtime
.read_plugin_catalog(params)
.await
.map_err(to_jsonrpc_error)?;
dispatch_result(response)
}
pub(super) async fn handle_plugin_catalog_install_v2_impl(
&self,
params: Option<serde_json::Value>,
) -> Result<RpcDispatch, JsonRpcError> {
self.ensure_initialized()?;
let params: PluginCatalogInstallParams = parse_params(params)?;
let response = self
.runtime
.install_plugin_catalog(params)
.await
.map_err(to_jsonrpc_error)?;
dispatch_result(response)
}
pub(super) async fn handle_plugin_catalog_uninstall_v2_impl(
&self,
params: Option<serde_json::Value>,
) -> Result<RpcDispatch, JsonRpcError> {
self.ensure_initialized()?;
let params: PluginCatalogUninstallParams = parse_params(params)?;
let response = self
.runtime
.uninstall_plugin_catalog(params)
.await
.map_err(to_jsonrpc_error)?;
dispatch_result(response)
}
pub(super) async fn handle_plugin_catalog_installed_v2_impl(
&self,
params: Option<serde_json::Value>,
) -> Result<RpcDispatch, JsonRpcError> {
self.ensure_initialized()?;
let params: PluginCatalogInstalledParams = parse_params(params)?;
let response = self
.runtime
.list_plugin_catalog_installed(params)
.await
.map_err(to_jsonrpc_error)?;
dispatch_result(response)
}
pub(super) async fn handle_plugin_catalog_enabled_set_v2_impl(
&self,
params: Option<serde_json::Value>,
) -> Result<RpcDispatch, JsonRpcError> {
self.ensure_initialized()?;
let params: PluginCatalogEnabledSetParams = parse_params(params)?;
let response = self
.runtime
.set_plugin_catalog_enabled(params)
.await
.map_err(to_jsonrpc_error)?;
dispatch_result(response)
}
pub(super) async fn handle_plugin_installed_list_impl(
&self,
) -> Result<RpcDispatch, JsonRpcError> {
@@ -408,6 +408,8 @@ fn project_item(item: canonical::ThreadItem) -> Result<v2::ThreadItem, JsonRpcEr
canonical::ThreadItemPayload::McpToolCall {
server_name,
tool_name,
mcp_app_resource_uri,
plugin_id,
arguments,
output,
..
@@ -435,8 +437,8 @@ fn project_item(item: canonical::ThreadItem) -> Result<v2::ThreadItem, JsonRpcEr
)
.0,
app_context: None,
mcp_app_resource_uri: None,
plugin_id: None,
mcp_app_resource_uri,
plugin_id,
result,
error,
duration_ms: output
@@ -217,6 +217,8 @@ fn canonical_mcp_output_projects_codex_result_shape_when_only_truncation_remains
call_id: "mcp-1".to_string(),
server_name: "node_repl".to_string(),
tool_name: "exec".to_string(),
mcp_app_resource_uri: None,
plugin_id: None,
arguments: Vec::new(),
output: Some(canonical::ToolOutput {
error: Some("tool output unavailable".to_string()),
@@ -267,6 +269,8 @@ fn canonical_mcp_output_is_size_bounded_and_redacts_sensitive_fields() {
call_id: "mcp-safe".to_string(),
server_name: "docs".to_string(),
tool_name: "search".to_string(),
mcp_app_resource_uri: Some("ui://plugin/docs.html".to_string()),
plugin_id: Some("docs-plugin".to_string()),
arguments: vec![canonical::ToolArgument {
name: "token".to_string(),
value: "secret-value".to_string(),
@@ -286,7 +290,11 @@ fn canonical_mcp_output_is_size_bounded_and_redacts_sensitive_fields() {
let projected = project_thread(thread).expect("project bounded MCP result");
let v2::ThreadItem::McpToolCall {
arguments, result, ..
arguments,
mcp_app_resource_uri,
plugin_id,
result,
..
} = &projected.turns[0].items[0]
else {
panic!("MCP item");
@@ -295,6 +303,11 @@ fn canonical_mcp_output_is_size_bounded_and_redacts_sensitive_fields() {
let wire = serde_json::to_value(result).expect("MCP result wire");
assert!(serde_json::to_vec(result).unwrap().len() <= MAX_DISPLAY_JSON_BYTES);
assert_eq!(arguments[0]["value"], "[redacted]");
assert_eq!(
mcp_app_resource_uri.as_deref(),
Some("ui://plugin/docs.html")
);
assert_eq!(plugin_id.as_deref(), Some("docs-plugin"));
assert_eq!(wire["structuredContent"]["password"], "[redacted]");
assert_eq!(wire["structuredContent"]["safe"], "visible");
assert_eq!(wire["_meta"]["truncated"], true);
+9
View File
@@ -459,6 +459,15 @@ pub trait ExecutionBackend: Send + Sync {
))
}
async fn read_mcp_runtime_resource(
&self,
_params: app_server_protocol::McpResourceReadParams,
) -> Result<app_server_protocol::McpResourceReadResponse, RuntimeCoreError> {
Err(RuntimeCoreError::Backend(
"runtime backend does not expose MCP resources".to_string(),
))
}
async fn prepare_runtime_worker_artifact_events(
&self,
_request: &ExecutionRequest,
@@ -1,8 +1,15 @@
use super::unavailable;
use super::NoopAppDataSource;
use super::RuntimeCoreError;
use app_server_protocol::protocol::v2::{
PluginCatalogEnabledSetParams, PluginCatalogEnabledSetResponse, PluginCatalogInstallParams,
PluginCatalogInstallResponse, PluginCatalogInstalledParams, PluginCatalogListParams,
PluginCatalogListResponse, PluginCatalogReadParams, PluginCatalogReadResponse,
PluginCatalogUninstallParams, PluginCatalogUninstallResponse,
};
use app_server_protocol::*;
use async_trait::async_trait;
use serde_json::Value;
use std::path::PathBuf;
#[async_trait]
@@ -11,6 +18,52 @@ pub trait PluginDataSource: Send + Sync {
Err(unavailable("pluginData/root"))
}
async fn list_plugin_catalog(
&self,
_params: PluginCatalogListParams,
) -> Result<PluginCatalogListResponse, RuntimeCoreError> {
Err(unavailable("plugin/list"))
}
async fn read_plugin_catalog(
&self,
_params: PluginCatalogReadParams,
) -> Result<PluginCatalogReadResponse, RuntimeCoreError> {
Err(unavailable("plugin/read"))
}
async fn install_plugin_catalog(
&self,
_params: PluginCatalogInstallParams,
) -> Result<PluginCatalogInstallResponse, RuntimeCoreError> {
Err(unavailable("plugin/install"))
}
async fn uninstall_plugin_catalog(
&self,
_params: PluginCatalogUninstallParams,
) -> Result<PluginCatalogUninstallResponse, RuntimeCoreError> {
Err(unavailable("plugin/uninstall"))
}
async fn list_plugin_catalog_installed(
&self,
_params: PluginCatalogInstalledParams,
) -> Result<PluginCatalogListResponse, RuntimeCoreError> {
Err(unavailable("plugin/installed"))
}
async fn list_plugin_catalog_activations(&self) -> Result<Vec<Value>, RuntimeCoreError> {
Ok(Vec::new())
}
async fn set_plugin_catalog_enabled(
&self,
_params: PluginCatalogEnabledSetParams,
) -> Result<PluginCatalogEnabledSetResponse, RuntimeCoreError> {
Err(unavailable("plugin/enabled/set"))
}
async fn list_plugin_installed(&self) -> Result<PluginInstalledListResponse, RuntimeCoreError> {
Ok(PluginInstalledListResponse::default())
}
@@ -1,5 +1,6 @@
use std::any::Any;
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use agent_protocol::{
CollabAgentState, CollabAgentStatus, SortDirection, Thread, ThreadActiveFlag,
@@ -48,6 +49,7 @@ use queries::{
};
const MAX_PAGE_SIZE: u32 = 500;
const THREAD_STORE_BUSY_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -374,6 +376,8 @@ impl ProjectionStore {
pub(super) fn open_thread_store(&self) -> ThreadStoreResult<Connection> {
let conn = Connection::open(self.state_path()).map_err(store_error)?;
conn.busy_timeout(THREAD_STORE_BUSY_TIMEOUT)
.map_err(store_error)?;
conn.execute_batch("PRAGMA foreign_keys = ON;")
.map_err(store_error)?;
if self.state_path() != self.thread_history_path() {
@@ -173,7 +173,7 @@ pub(super) fn apply_change_set(
upsert_turn(tx, turn, params.changes.sequence)?;
}
for item in &params.changes.changed_items {
upsert_item(tx, item)?;
upsert_item_snapshot(tx, item)?;
}
Ok(())
}
@@ -221,7 +221,7 @@ fn upsert_turn(tx: &Transaction<'_>, turn: &Turn, sequence: u64) -> ThreadStoreR
Ok(())
}
fn upsert_item(tx: &Transaction<'_>, item: &ThreadItem) -> ThreadStoreResult<()> {
fn upsert_item_snapshot(tx: &Transaction<'_>, item: &ThreadItem) -> ThreadStoreResult<()> {
if item.kind != item.payload_kind() {
return Err(error(format!(
"item {} kind does not match payload",
@@ -230,24 +230,20 @@ fn upsert_item(tx: &Transaction<'_>, item: &ThreadItem) -> ThreadStoreResult<()>
}
let existing = tx
.query_row(
"SELECT turn_id, item_json FROM canonical_items WHERE thread_id = ?1 AND item_id = ?2",
"SELECT turn_id FROM canonical_items WHERE thread_id = ?1 AND item_id = ?2",
params![item.thread_id.as_str(), item.item_id.as_str()],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|row| row.get::<_, String>(0),
)
.optional()
.map_err(store_error)?;
let snapshot = if let Some((existing_turn_id, json)) = existing {
if let Some(existing_turn_id) = existing {
if existing_turn_id != item.turn_id.as_str() {
return Err(error(format!(
"item {} changed turn identity",
item.item_id
)));
}
let previous = decode_json(&json)?;
super::super::thread_item_projection::merge_item_snapshot(previous, item.clone())
} else {
item.clone()
};
}
tx.execute(
"INSERT INTO canonical_items (
thread_id, turn_id, item_id, ordinal, sequence, item_json
@@ -257,12 +253,12 @@ fn upsert_item(tx: &Transaction<'_>, item: &ThreadItem) -> ThreadStoreResult<()>
sequence = excluded.sequence,
item_json = excluded.item_json",
params![
snapshot.thread_id.as_str(),
snapshot.turn_id.as_str(),
snapshot.item_id.as_str(),
to_i64(snapshot.ordinal, "item ordinal")?,
to_i64(snapshot.sequence, "item sequence")?,
encode_json(&snapshot)?,
item.thread_id.as_str(),
item.turn_id.as_str(),
item.item_id.as_str(),
to_i64(item.ordinal, "item ordinal")?,
to_i64(item.sequence, "item sequence")?,
encode_json(item)?,
],
)
.map_err(|source| error(format!("cannot persist canonical item: {source}")))?;
@@ -101,6 +101,17 @@ fn production_storage_paths_keep_physical_table_owners_separate() {
);
}
#[test]
fn canonical_store_connections_wait_for_transient_sqlite_locks() {
let (_temp, store) = store();
let conn = store.open_thread_store().expect("open canonical store");
let busy_timeout_ms = conn
.query_row("PRAGMA busy_timeout", [], |row| row.get::<_, u64>(0))
.expect("read SQLite busy timeout");
assert_eq!(busy_timeout_ms, 5_000);
}
#[test]
fn queued_recovery_requires_a_current_canonical_thread_owner() {
let temp = tempfile::tempdir().expect("tempdir");
@@ -1835,6 +1846,190 @@ fn production_event_batches_create_and_incrementally_update_canonical_history()
));
}
#[test]
fn reasoning_deltas_persist_linearly_in_canonical_history_and_rollout() {
let (_temp, agent_root, store) = store_with_rollout();
let stored = StoredSession {
session: AgentSession {
session_id: "session-reasoning-linear".to_string(),
thread_id: "thread-reasoning-linear".to_string(),
app_id: "agent-chat".to_string(),
workspace_id: None,
business_object_ref: None,
status: AgentSessionStatus::Running,
created_at: "2026-08-05T00:00:00Z".to_string(),
updated_at: "2026-08-05T00:00:01Z".to_string(),
},
turns: Vec::new(),
turn_inputs: HashMap::new(),
turn_runtime_options: HashMap::new(),
events: Vec::new(),
output_blobs: HashMap::new(),
};
let fragments = [
"The", " user", " said", " hello", ".", " Respond", " briefly", " now",
];
for (index, fragment) in fragments.iter().enumerate() {
let sequence = (index + 1) as u64;
store
.apply_canonical_events(
&stored,
&[AgentEvent {
event_id: format!("reasoning-event-{sequence}"),
sequence,
session_id: stored.session.session_id.clone(),
thread_id: Some(stored.session.thread_id.clone()),
turn_id: Some("turn-reasoning-linear".to_string()),
event_type: "reasoning.delta".to_string(),
timestamp: format!("2026-08-05T00:00:{sequence:02}Z"),
payload: json!({
"reasoningId": "reasoning-linear",
"delta": fragment,
}),
}],
)
.expect("persist reasoning delta");
}
let thread = block_on(store.read_thread(ReadThreadParams {
thread_id: ThreadId::new(&stored.session.thread_id),
include_archived: false,
turns_view: ThreadTurnsView::Full,
}))
.expect("read canonical reasoning thread")
.expect("canonical reasoning thread");
let reasoning_content = thread.turns[0]
.items
.iter()
.find_map(|item| match &item.payload {
ThreadItemPayload::Reasoning { content, .. } => Some(content.clone()),
_ => None,
})
.expect("canonical reasoning item");
assert_eq!(reasoning_content, fragments.map(str::to_string).to_vec());
let path = rollout_path(
&store,
&agent_root,
&ThreadId::new(&stored.session.thread_id),
);
let lines = rollout_lines(&path);
assert_eq!(lines.len(), fragments.len() + 1);
for (index, line) in lines.iter().skip(1).enumerate() {
let changes: ThreadHistoryChangeSet = serde_json::from_value(line["changes"].clone())
.expect("decode rollout history changes");
let content = changes
.changed_items
.iter()
.find_map(|item| match &item.payload {
ThreadItemPayload::Reasoning { content, .. } => Some(content),
_ => None,
})
.expect("rollout reasoning item");
assert_eq!(content.len(), index + 1);
assert_eq!(content.last().map(String::as_str), Some(fragments[index]));
}
}
#[test]
fn empty_reasoning_completion_preserves_canonical_content_across_persistence() {
let (_temp, store) = store();
let stored = StoredSession {
session: AgentSession {
session_id: "session-reasoning-empty-completion".to_string(),
thread_id: "thread-reasoning-empty-completion".to_string(),
app_id: "agent-chat".to_string(),
workspace_id: None,
business_object_ref: None,
status: AgentSessionStatus::Running,
created_at: "2026-08-05T00:00:00Z".to_string(),
updated_at: "2026-08-05T00:00:01Z".to_string(),
},
turns: Vec::new(),
turn_inputs: HashMap::new(),
turn_runtime_options: HashMap::new(),
events: Vec::new(),
output_blobs: HashMap::new(),
};
let reasoning_item_id = "item_reasoning-turn-reasoning-empty-completion";
let event = |sequence, event_type: &str, payload| AgentEvent {
event_id: format!("reasoning-empty-event-{sequence}"),
sequence,
session_id: stored.session.session_id.clone(),
thread_id: Some(stored.session.thread_id.clone()),
turn_id: Some("turn-reasoning-empty-completion".to_string()),
event_type: event_type.to_string(),
timestamp: format!("2026-08-05T00:00:{sequence:02}Z"),
payload,
};
store
.apply_canonical_events(
&stored,
&[event(
1,
"reasoning.delta",
json!({
"reasoningId": "reasoning-empty-completion",
"text": "inspect inputs"
}),
)],
)
.expect("persist reasoning content");
store
.apply_canonical_events(
&stored,
&[event(
2,
"item.completed",
json!({
"item": {
"sessionId": stored.session.session_id.clone(),
"threadId": stored.session.thread_id.clone(),
"turnId": "turn-reasoning-empty-completion",
"itemId": reasoning_item_id,
"sequence": 2,
"ordinal": 2,
"createdAtMs": 2,
"updatedAtMs": 2,
"completedAtMs": 2,
"kind": "reasoning",
"status": "completed",
"payload": {
"type": "reasoning",
"summary": [],
"content": []
},
"metadata": {}
}
}),
)],
)
.expect("persist empty reasoning completion");
let thread = block_on(store.read_thread(ReadThreadParams {
thread_id: ThreadId::new(&stored.session.thread_id),
include_archived: false,
turns_view: ThreadTurnsView::Full,
}))
.expect("read persisted reasoning thread")
.expect("persisted reasoning thread");
let reasoning = thread.turns[0]
.items
.iter()
.find(|item| matches!(item.payload, ThreadItemPayload::Reasoning { .. }))
.expect("persisted reasoning item");
assert_eq!(reasoning.status, ItemStatus::Completed);
assert_eq!(
reasoning.payload,
ThreadItemPayload::Reasoning {
summary: Vec::new(),
content: vec!["inspect inputs".to_string()],
}
);
}
#[test]
fn canonical_live_cold_and_replay_projections_are_equivalent() {
let stored = StoredSession {
@@ -458,6 +458,8 @@ fn canonical_tool_payload(
call_id: call_id.to_string(),
server_name,
tool_name: mcp_tool_name,
mcp_app_resource_uri: None,
plugin_id: None,
arguments,
output,
},
@@ -97,6 +97,7 @@ pub(super) fn canonical_tool(event: &AgentEvent) -> Option<CanonicalTool> {
tool_name,
arguments,
output,
..
} => (
call_id,
tool_name,
+11 -1
View File
@@ -129,7 +129,17 @@ impl RuntimeCore {
&self,
params: McpResourceReadParams,
) -> Result<McpResourceReadResponse, RuntimeCoreError> {
self.app_data_source.read_mcp_resource(params).await
match (&params.session_id, &params.thread_id) {
(None, None) => self.app_data_source.read_mcp_resource(params).await,
(Some(session_id), Some(thread_id))
if !session_id.trim().is_empty() && !thread_id.trim().is_empty() =>
{
self.backend.read_mcp_runtime_resource(params).await
}
_ => Err(RuntimeCoreError::InvalidRequest(
"mcpResource/read sessionId and threadId must be provided together".to_string(),
)),
}
}
pub async fn subscribe_mcp_resource(
@@ -38,6 +38,8 @@ fn canonical_tool_payload(
call_id: "call-mcp-large".to_string(),
server_name: "files".to_string(),
tool_name: "read_resource".to_string(),
mcp_app_resource_uri: None,
plugin_id: None,
arguments: Vec::new(),
output,
},
@@ -6,6 +6,17 @@ use super::plugin_task_runtime::{
use super::timestamp;
use super::RuntimeCore;
use super::RuntimeCoreError;
use app_server_protocol::protocol::v2::PluginCatalogEnabledSetParams;
use app_server_protocol::protocol::v2::PluginCatalogEnabledSetResponse;
use app_server_protocol::protocol::v2::PluginCatalogInstallParams;
use app_server_protocol::protocol::v2::PluginCatalogInstallResponse;
use app_server_protocol::protocol::v2::PluginCatalogInstalledParams;
use app_server_protocol::protocol::v2::PluginCatalogListParams;
use app_server_protocol::protocol::v2::PluginCatalogListResponse;
use app_server_protocol::protocol::v2::PluginCatalogReadParams;
use app_server_protocol::protocol::v2::PluginCatalogReadResponse;
use app_server_protocol::protocol::v2::PluginCatalogUninstallParams;
use app_server_protocol::protocol::v2::PluginCatalogUninstallResponse;
use app_server_protocol::PluginFetchCloudPackageParams;
use app_server_protocol::PluginInstalledDisabledSetParams;
use app_server_protocol::PluginInstalledListResponse;
@@ -70,6 +81,52 @@ struct PluginShellDescriptorFields {
}
impl RuntimeCore {
pub async fn list_plugin_catalog(
&self,
params: PluginCatalogListParams,
) -> Result<PluginCatalogListResponse, RuntimeCoreError> {
self.app_data_source.list_plugin_catalog(params).await
}
pub async fn read_plugin_catalog(
&self,
params: PluginCatalogReadParams,
) -> Result<PluginCatalogReadResponse, RuntimeCoreError> {
self.app_data_source.read_plugin_catalog(params).await
}
pub async fn install_plugin_catalog(
&self,
params: PluginCatalogInstallParams,
) -> Result<PluginCatalogInstallResponse, RuntimeCoreError> {
self.app_data_source.install_plugin_catalog(params).await
}
pub async fn uninstall_plugin_catalog(
&self,
params: PluginCatalogUninstallParams,
) -> Result<PluginCatalogUninstallResponse, RuntimeCoreError> {
self.app_data_source.uninstall_plugin_catalog(params).await
}
pub async fn list_plugin_catalog_installed(
&self,
params: PluginCatalogInstalledParams,
) -> Result<PluginCatalogListResponse, RuntimeCoreError> {
self.app_data_source
.list_plugin_catalog_installed(params)
.await
}
pub async fn set_plugin_catalog_enabled(
&self,
params: PluginCatalogEnabledSetParams,
) -> Result<PluginCatalogEnabledSetResponse, RuntimeCoreError> {
self.app_data_source
.set_plugin_catalog_enabled(params)
.await
}
pub(super) fn installed_plugin_runtime_dir(
&self,
state: &serde_json::Value,
@@ -342,6 +342,8 @@ mod tests {
call_id: "mcp-call".to_string(),
server_name: "docs".to_string(),
tool_name: tool_name.to_string(),
mcp_app_resource_uri: None,
plugin_id: None,
arguments: vec![ToolArgument {
name: "query".to_string(),
value: "\"fork\"".to_string(),
@@ -252,6 +252,8 @@ fn canonical_payload_to_agent_detail(
call_id,
server_name,
tool_name,
mcp_app_resource_uri,
plugin_id,
arguments,
output,
} => {
@@ -259,6 +261,12 @@ fn canonical_payload_to_agent_detail(
detail.insert("mcp_server".to_string(), json!(server_name));
detail.insert("tool_name".to_string(), json!(tool_name));
detail.insert("arguments".to_string(), json!(arguments));
if let Some(resource_uri) = mcp_app_resource_uri {
detail.insert("mcp_app_resource_uri".to_string(), json!(resource_uri));
}
if let Some(plugin_id) = plugin_id {
detail.insert("plugin_id".to_string(), json!(plugin_id));
}
if let Some(output) = output {
insert_tool_output(&mut detail, output);
}
@@ -173,6 +173,8 @@ async fn fork_uses_codex_sanitize_profile_for_internal_items() {
call_id: "mcp-call".to_string(),
server_name: "docs".to_string(),
tool_name: "search".to_string(),
mcp_app_resource_uri: None,
plugin_id: None,
arguments: Vec::new(),
output: Some(ToolOutput {
text: Some("mcp output".to_string()),
@@ -7,7 +7,7 @@ pub(super) mod materializer;
pub(in crate::runtime) mod media_result;
mod plan;
pub(in crate::runtime) use change_set::{merge_item_snapshot, merge_turn_snapshot};
pub(in crate::runtime) use change_set::merge_turn_snapshot;
pub(in crate::runtime) use materializer::{materialize_events, IncrementalMaterializer};
#[cfg(test)]
@@ -323,8 +323,16 @@ fn merge_payload(
) => match source_event_type {
Some("reasoning.final" | "item.completed" | "item.updated") => {
ThreadItemPayload::Reasoning {
summary: next_summary,
content: next_content,
summary: if next_summary.is_empty() {
summary
} else {
next_summary
},
content: if next_content.is_empty() {
content
} else {
next_content
},
}
}
_ => {
@@ -365,6 +373,8 @@ fn merge_payload(
call_id: previous_call_id,
server_name: previous_server_name,
tool_name: previous_tool_name,
mcp_app_resource_uri: previous_mcp_app_resource_uri,
plugin_id: previous_plugin_id,
arguments: previous_arguments,
output: previous_output,
},
@@ -372,6 +382,8 @@ fn merge_payload(
call_id,
server_name,
tool_name,
mcp_app_resource_uri,
plugin_id,
arguments,
output,
},
@@ -379,6 +391,8 @@ fn merge_payload(
call_id: prefer_string(previous_call_id, call_id, ""),
server_name: prefer_string(previous_server_name, server_name, "unknown"),
tool_name: prefer_string(previous_tool_name, tool_name, "tool"),
mcp_app_resource_uri: mcp_app_resource_uri.or(previous_mcp_app_resource_uri),
plugin_id: plugin_id.or(previous_plugin_id),
arguments: if arguments.is_empty() {
previous_arguments
} else {
@@ -244,6 +244,11 @@ pub(super) fn typed_payload(
.unwrap_or_else(|| "unknown".to_string()),
tool_name: map_string(payload, &["toolName", "tool_name", "name"])
.unwrap_or_else(|| "tool".to_string()),
mcp_app_resource_uri: map_string(
payload,
&["mcpAppResourceUri", "mcp_app_resource_uri"],
),
plugin_id: map_string(payload, &["pluginId", "plugin_id"]),
arguments: tool_arguments(payload),
output: tool_output(payload),
},
@@ -106,6 +106,97 @@ fn reasoning_deltas_preserve_repeated_fragments_and_final_snapshot() {
);
}
#[test]
fn empty_reasoning_completion_preserves_materialized_content() {
let delta = materialize_events(
&[
event(
"reasoning-started",
1,
"reasoning.started",
"turn-1",
json!({"reasoningId": "reasoning-1"}),
),
event(
"reasoning-delta",
2,
"reasoning.delta",
"turn-1",
json!({"reasoningId": "reasoning-1", "text": "inspect inputs"}),
),
],
"session-1",
"thread-1",
)
.expect("materialize reasoning delta");
let mut completed_item = delta
.changed_items
.into_iter()
.find(|item| {
matches!(
item.payload,
agent_protocol::ThreadItemPayload::Reasoning { .. }
)
})
.expect("reasoning delta item");
completed_item.status = agent_protocol::ItemStatus::Completed;
completed_item.completed_at_ms = Some(3);
completed_item.sequence = 3;
completed_item.updated_at_ms = 3;
completed_item.metadata = json!({"source_event_type": "item.completed"});
completed_item.payload = agent_protocol::ThreadItemPayload::Reasoning {
summary: Vec::new(),
content: Vec::new(),
};
let changes = materialize_events(
&[
event(
"reasoning-started",
1,
"reasoning.started",
"turn-1",
json!({"reasoningId": "reasoning-1"}),
),
event(
"reasoning-delta",
2,
"reasoning.delta",
"turn-1",
json!({"reasoningId": "reasoning-1", "text": "inspect inputs"}),
),
event(
"reasoning-completed",
3,
"item.completed",
"turn-1",
json!({"item": serde_json::to_value(completed_item).expect("serialize item")}),
),
],
"session-1",
"thread-1",
)
.expect("materialize empty reasoning completion");
let reasoning = changes
.changed_items
.iter()
.find(|item| {
matches!(
item.payload,
agent_protocol::ThreadItemPayload::Reasoning { .. }
)
})
.expect("completed reasoning item");
assert_eq!(reasoning.status, agent_protocol::ItemStatus::Completed);
assert_eq!(
reasoning.payload,
agent_protocol::ThreadItemPayload::Reasoning {
summary: Vec::new(),
content: vec!["inspect inputs".to_string()],
}
);
}
#[test]
fn incremental_materializer_does_not_revive_removed_item_identity() {
let started = event(
@@ -65,11 +65,14 @@ mod request_context;
pub(crate) use model_routing::configured_provider_readiness;
pub(crate) use provider_config::current_agent_runtime_config_metadata;
use provider_config::{initialize_runtime_database, model_effective_event_from_runtime};
#[cfg(test)]
use request_context::session_config_from_request;
use request_context::{
apply_app_server_turn_policy, direct_provider_config_from_request,
request_tool_policy_from_request, resolve_runtime_model_selection,
runtime_request_from_request, service_tier_from_request, session_config_from_request,
session_scope_from_request, should_use_compact_tool_surface,
runtime_request_from_request, service_tier_from_request,
session_config_from_request_with_plugin_activations, session_scope_from_request,
should_use_compact_tool_surface,
};
use route_support::{
agent_control_route_snapshot_for_resolved_route, durable_credential_ref_for_generation,
@@ -418,8 +421,15 @@ impl RuntimeBackend {
let soul_style = tool_process_metadata::SoulStyleMetadata::from_config_metadata(
config_metadata.as_ref(),
);
let mention_selection =
mention_selection::resolve_mentions(&request, self.current_app_data_source()?).await;
let plugin_activations = self.current_plugin_activations().await?;
let mention_selection = mention_selection::resolve_mentions(
&request,
self.current_app_data_source()?,
&plugin_activations,
)
.await;
let turn_plugin_activations =
mention_selection.plugin_activations_for_turn(&plugin_activations);
let mut emit_error = None;
let mut coding_event_mirror = coding_events::CodingEventMirror::default();
let mut proposed_plan_parser = proposed_plan_parser::ProposedPlanParser::default();
@@ -493,13 +503,14 @@ impl RuntimeBackend {
runtime_initialized = true;
}
let mut session_config = session_config_from_request(
let mut session_config = session_config_from_request_with_plugin_activations(
&request,
host_request.as_ref(),
&session_scope,
&selection,
&request_tool_policy,
config_metadata.clone(),
&turn_plugin_activations,
);
mention_selection.apply_to_session_config(&mut session_config);
let model_context_window = lime_agent::model_request_policy_from_turn_context(
@@ -710,6 +721,13 @@ impl RuntimeBackend {
})
.map(|guard| guard.clone())
}
async fn current_plugin_activations(&self) -> Result<Vec<Value>, RuntimeCoreError> {
let Some(app_data_source) = self.current_app_data_source()? else {
return Ok(Vec::new());
};
app_data_source.list_plugin_catalog_activations().await
}
}
fn backend_error(error: impl std::fmt::Display) -> RuntimeCoreError {
@@ -1,7 +1,7 @@
use super::{
action_response, current_agent_runtime_config_metadata, initialize_runtime_database,
plugin_worker_generation, request_context::effective_runtime_options_for_turn, tool_inventory,
workspace_patch_host_execution, RuntimeBackend,
mcp_bridges, plugin_worker_generation, request_context::effective_runtime_options_for_turn,
tool_inventory, workspace_patch_host_execution, RuntimeBackend,
};
use crate::runtime::ToolInventoryReadRequest;
use crate::{
@@ -346,6 +346,43 @@ impl ExecutionBackend for RuntimeBackend {
.await
}
async fn read_mcp_runtime_resource(
&self,
params: app_server_protocol::McpResourceReadParams,
) -> Result<app_server_protocol::McpResourceReadResponse, RuntimeCoreError> {
let session_id = params.session_id.as_deref().ok_or_else(|| {
RuntimeCoreError::InvalidRequest(
"mcpResource/read runtime owner requires sessionId".to_string(),
)
})?;
let thread_id = params.thread_id.as_deref().ok_or_else(|| {
RuntimeCoreError::InvalidRequest(
"mcpResource/read runtime owner requires threadId".to_string(),
)
})?;
let db = initialize_runtime_database(self.db.as_ref())?;
self.ensure_agent_initialized(&db).await?;
mcp_bridges::ensure_thread_mcp_runtime_if_available(
&self.agent_state,
&self.app_data_source,
session_id,
thread_id,
)
.await?;
let content = self
.agent_state
.read_mcp_resource(session_id, thread_id, &params.server, &params.uri)
.await
.map_err(RuntimeCoreError::Backend)?;
Ok(app_server_protocol::McpResourceReadResponse {
uri: content.uri,
mime_type: content.mime_type,
text: content.text,
blob: content.blob,
meta: content.meta,
})
}
async fn prepare_runtime_worker_artifact_events(
&self,
request: &ExecutionRequest,
@@ -170,6 +170,30 @@ async fn main_turn_initializes_agent_before_live_execution_hook() {
assert!(backend.agent_state.is_initialized().await);
}
#[tokio::test]
async fn mcp_resource_read_rehydrates_session_runtime_without_starting_turn() {
let backend = RuntimeBackend::with_db(test_db());
ExecutionBackend::set_app_data_source(&backend, Arc::new(crate::NoopAppDataSource))
.expect("set app data source");
assert!(!backend.agent_state.is_initialized().await);
let error = ExecutionBackend::read_mcp_runtime_resource(
&backend,
app_server_protocol::McpResourceReadParams {
server: "missing-server".to_string(),
uri: "ui://missing-server/app.html".to_string(),
session_id: Some("session-history".to_string()),
thread_id: Some("thread-history".to_string()),
},
)
.await
.expect_err("empty fixture runtime has no MCP server");
assert!(backend.agent_state.is_initialized().await);
assert!(!error.to_string().contains("MCP runtime is not initialized"));
}
#[tokio::test]
async fn unexecutable_route_returns_typed_rejection_before_provider_call() {
let backend = RuntimeBackend::with_db(test_db());
@@ -7,13 +7,16 @@ use std::sync::Arc;
const APP_MENTION_PREFIX: &str = "app://";
const PLUGIN_MENTION_PREFIX: &str = "plugin://";
const RUNTIME_CAPABILITY_SCHEMA_VERSION: &str = "plugin-runtime-capabilities/v0.1";
const PLUGIN_ACTIVATION_SCHEMA_VERSION: &str = "plugin-activation/v2";
const PLUGIN_RUNTIME_CAPABILITY_SCHEMA_VERSION: &str = "plugin-runtime-capabilities/v2";
const LEGACY_APP_RUNTIME_CAPABILITY_SCHEMA_VERSION: &str = "plugin-runtime-capabilities/v0.1";
const MENTION_SELECTION_TURN_METADATA_KEY: &str = "mention_selection";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(super) struct MentionSelection {
apps: Vec<ResolvedAppMention>,
plugins: Vec<ResolvedPluginMention>,
plugin_filter_requested: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -29,11 +32,17 @@ struct ResolvedPluginMention {
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct InstalledPluginRecord {
struct PluginActivationRecord {
id: String,
runtime_capabilities: Value,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct InstalledAppRecord {
plugin_id: String,
runtime_capabilities: Value,
}
#[derive(Debug, Default, PartialEq, Eq)]
struct MentionRequests {
app_ids: Vec<String>,
@@ -43,35 +52,64 @@ struct MentionRequests {
pub(super) async fn resolve_mentions(
request: &ExecutionRequest,
app_data_source: Option<Arc<dyn AppDataSource>>,
plugin_activations: &[Value],
) -> MentionSelection {
let requests = mention_requests(request);
if requests.app_ids.is_empty() && requests.plugin_ids.is_empty() {
return MentionSelection::default();
}
let Some(app_data_source) = app_data_source else {
let installed_app_states = if requests.app_ids.is_empty() {
Vec::new()
} else if let Some(app_data_source) = app_data_source {
match app_data_source.list_plugin_installed().await {
Ok(installed) => installed.states,
Err(error) => {
tracing::warn!(
error = %error,
"App installed registry 不可用,app:// Mention 按 fail-closed 处理"
);
Vec::new()
}
}
} else {
tracing::warn!(
app_count = requests.app_ids.len(),
plugin_count = requests.plugin_ids.len(),
"忽略无法由 installed registry 校验的结构化 Mention"
"忽略无法由 app installed registry 校验的 app:// Mention"
);
return MentionSelection::default();
};
let installed = match app_data_source.list_plugin_installed().await {
Ok(installed) => installed,
Err(error) => {
tracing::warn!(
error = %error,
"Plugin installed registry 不可用,结构化 Mention 按 fail-closed 处理"
);
return MentionSelection::default();
}
Vec::new()
};
resolve_from_installed_states(requests, &installed.states)
if !requests.plugin_ids.is_empty() && plugin_activations.is_empty() {
tracing::warn!(
plugin_count = requests.plugin_ids.len(),
"Plugin v2 activation snapshot 为空,plugin:// Mention 按 fail-closed 处理"
);
}
resolve_from_sources(requests, plugin_activations, &installed_app_states)
}
impl MentionSelection {
pub(super) fn plugin_activations_for_turn(&self, activations: &[Value]) -> Vec<Value> {
if !self.plugin_filter_requested {
return activations.to_vec();
}
let selected_ids = self
.plugins
.iter()
.map(|plugin| plugin.id.as_str())
.collect::<HashSet<_>>();
activations
.iter()
.filter(|activation| {
string_at(activation, "/pluginId")
.is_some_and(|id| selected_ids.contains(id.as_str()))
})
.cloned()
.collect()
}
pub(super) fn apply_to_session_config(&self, session_config: &mut AgentSessionConfig) {
if self.apps.is_empty() && self.plugins.is_empty() {
return;
@@ -94,22 +132,6 @@ impl MentionSelection {
})).collect::<Vec<_>>(),
}),
);
let plugin_metadata = self
.plugins
.iter()
.map(|plugin| {
json!({
"plugin_runtime_capabilities": plugin.runtime_capabilities,
})
})
.collect::<Vec<_>>();
let plugin_metadata = plugin_metadata.iter().collect::<Vec<_>>();
session_config.system_prompt =
super::plugin_runtime_context::append_plugin_runtime_context_to_system_prompt(
session_config.system_prompt.take(),
&plugin_metadata,
);
}
}
@@ -139,20 +161,28 @@ fn mention_id<'a>(path: &'a str, prefix: &str) -> Option<&'a str> {
path.strip_prefix(prefix).filter(|id| !id.is_empty())
}
fn resolve_from_installed_states(requests: MentionRequests, states: &[Value]) -> MentionSelection {
let records = states
fn resolve_from_sources(
requests: MentionRequests,
plugin_activations: &[Value],
installed_app_states: &[Value],
) -> MentionSelection {
let plugin_records = plugin_activations
.iter()
.filter_map(installed_plugin_record)
.filter_map(plugin_activation_record)
.collect::<Vec<_>>();
let app_records = installed_app_states
.iter()
.filter_map(installed_app_record)
.collect::<Vec<_>>();
let plugins = requests
.plugin_ids
.iter()
.filter_map(|id| resolve_plugin_mention(id, &records))
.filter_map(|id| resolve_plugin_mention(id, &plugin_records))
.collect::<Vec<_>>();
let apps = requests
.app_ids
.iter()
.filter_map(|id| resolve_app_mention(id, &records))
.filter_map(|id| resolve_app_mention(id, &app_records))
.collect::<Vec<_>>();
let unresolved_plugin_count = requests.plugin_ids.len().saturating_sub(plugins.len());
@@ -165,18 +195,41 @@ fn resolve_from_installed_states(requests: MentionRequests, states: &[Value]) ->
);
}
MentionSelection { apps, plugins }
MentionSelection {
apps,
plugins,
plugin_filter_requested: !requests.plugin_ids.is_empty(),
}
}
fn installed_plugin_record(state: &Value) -> Option<InstalledPluginRecord> {
fn plugin_activation_record(activation: &Value) -> Option<PluginActivationRecord> {
if string_at(activation, "/schemaVersion").as_deref() != Some(PLUGIN_ACTIVATION_SCHEMA_VERSION)
{
return None;
}
let id = string_at(activation, "/pluginId")?;
let runtime_capabilities = activation.get("runtimeCapabilities")?.clone();
if string_at(&runtime_capabilities, "/schemaVersion").as_deref()
!= Some(PLUGIN_RUNTIME_CAPABILITY_SCHEMA_VERSION)
|| string_at(&runtime_capabilities, "/pluginId").as_deref() != Some(id.as_str())
{
return None;
}
Some(PluginActivationRecord {
id,
runtime_capabilities,
})
}
fn installed_app_record(state: &Value) -> Option<InstalledAppRecord> {
if state.get("disabled").and_then(Value::as_bool) != Some(false) {
return None;
}
let id = string_at(state, "/appId")?;
let plugin_id = string_at(state, "/appId")?;
let mut runtime_capabilities = state.pointer("/manifest/runtimeCapabilities")?.clone();
if string_at(&runtime_capabilities, "/schemaVersion").as_deref()
!= Some(RUNTIME_CAPABILITY_SCHEMA_VERSION)
|| string_at(&runtime_capabilities, "/pluginId").as_deref() != Some(id.as_str())
!= Some(LEGACY_APP_RUNTIME_CAPABILITY_SCHEMA_VERSION)
|| string_at(&runtime_capabilities, "/pluginId").as_deref() != Some(plugin_id.as_str())
{
return None;
}
@@ -186,15 +239,15 @@ fn installed_plugin_record(state: &Value) -> Option<InstalledPluginRecord> {
.entry("packageSourceUri".to_string())
.or_insert(Value::String(package_source_uri));
}
Some(InstalledPluginRecord {
id,
Some(InstalledAppRecord {
plugin_id,
runtime_capabilities,
})
}
fn resolve_plugin_mention(
id: &str,
records: &[InstalledPluginRecord],
records: &[PluginActivationRecord],
) -> Option<ResolvedPluginMention> {
let mut matches = records.iter().filter(|record| record.id == id);
let record = matches.next()?;
@@ -207,7 +260,7 @@ fn resolve_plugin_mention(
})
}
fn resolve_app_mention(id: &str, records: &[InstalledPluginRecord]) -> Option<ResolvedAppMention> {
fn resolve_app_mention(id: &str, records: &[InstalledAppRecord]) -> Option<ResolvedAppMention> {
let mut matches = records.iter().filter(|record| {
record
.runtime_capabilities
@@ -227,7 +280,7 @@ fn resolve_app_mention(id: &str, records: &[InstalledPluginRecord]) -> Option<Re
}
Some(ResolvedAppMention {
id: id.to_string(),
plugin_id: record.id.clone(),
plugin_id: record.plugin_id.clone(),
})
}
@@ -267,7 +320,7 @@ mod tests {
}
#[test]
fn installed_registry_resolution_is_exact_unique_and_fail_closed() {
fn plugin_mentions_only_resolve_from_v2_activation_snapshot() {
let requests = MentionRequests {
app_ids: vec![
"docs".to_string(),
@@ -276,25 +329,32 @@ mod tests {
],
plugin_ids: vec![
"creator".to_string(),
"disabled".to_string(),
"legacy-only".to_string(),
"drifted".to_string(),
"unmarked".to_string(),
"duplicate".to_string(),
],
};
let mut unmarked = installed_state("unmarked", false, "unmarked", &["unmarked-app"]);
unmarked
let mut unmarked = plugin_activation("unmarked", "unmarked");
unmarked["runtimeCapabilities"]
.as_object_mut()
.expect("installed state object")
.remove("disabled");
.expect("runtime capabilities object")
.remove("schemaVersion");
let activations = vec![
plugin_activation("creator", "creator"),
plugin_activation("drifted", "different-id"),
unmarked,
plugin_activation("duplicate", "duplicate"),
plugin_activation("duplicate", "duplicate"),
];
let states = vec![
installed_state("creator", false, "creator", &["docs", "shared"]),
installed_state("other", false, "other", &["shared"]),
installed_state("disabled", true, "disabled", &["disabled-app"]),
installed_state("drifted", false, "different-id", &["drifted-app"]),
unmarked,
installed_state("legacy-only", false, "legacy-only", &["legacy-app"]),
];
let selection = resolve_from_installed_states(requests, &states);
let selection = resolve_from_sources(requests, &activations, &states);
let selected_activations = selection.plugin_activations_for_turn(&activations);
assert_eq!(
selection.apps,
@@ -305,19 +365,44 @@ mod tests {
);
assert_eq!(selection.plugins.len(), 1);
assert_eq!(selection.plugins[0].id, "creator");
assert_eq!(selected_activations.len(), 1);
assert_eq!(selected_activations[0]["pluginId"], "creator");
assert_eq!(
selection.plugins[0].runtime_capabilities["packageSourceUri"],
"/plugins/creator"
);
}
#[test]
fn unresolved_plugin_mention_does_not_fall_back_to_all_activations() {
let activations = vec![plugin_activation("creator", "creator")];
let selection = resolve_from_sources(
MentionRequests {
app_ids: Vec::new(),
plugin_ids: vec!["missing".to_string()],
},
&activations,
&[],
);
assert!(selection.plugins.is_empty());
assert!(selection
.plugin_activations_for_turn(&activations)
.is_empty());
assert_eq!(
MentionSelection::default().plugin_activations_for_turn(&activations),
activations
);
}
#[test]
fn resolved_selection_adds_internal_context_without_raw_mentions() {
let selection = resolve_from_installed_states(
let selection = resolve_from_sources(
MentionRequests {
app_ids: vec!["docs".to_string()],
plugin_ids: vec!["creator".to_string()],
},
&[plugin_activation("creator", "creator")],
&[installed_state("creator", false, "creator", &["docs"])],
);
let mut session_config = lime_agent::AgentSessionConfig {
@@ -342,9 +427,7 @@ mod tests {
assert_eq!(metadata["apps"][0]["id"], "docs");
assert_eq!(metadata["plugins"][0]["id"], "creator");
let prompt = session_config.system_prompt.expect("system prompt");
assert_eq!(prompt.matches("<plugin_runtime_capabilities>").count(), 1);
assert!(prompt.contains("<plugin_runtime_capabilities>"));
assert!(prompt.contains("plugin_id: creator"));
assert_eq!(prompt, "base");
assert!(!prompt.contains("plugin://creator"));
assert!(!prompt.contains("app://docs"));
}
@@ -370,7 +453,7 @@ mod tests {
},
"manifest": {
"runtimeCapabilities": {
"schemaVersion": RUNTIME_CAPABILITY_SCHEMA_VERSION,
"schemaVersion": LEGACY_APP_RUNTIME_CAPABILITY_SCHEMA_VERSION,
"pluginId": capability_plugin_id,
"skills": [],
"tools": connectors.iter().map(|id| json!({
@@ -383,4 +466,23 @@ mod tests {
}
})
}
fn plugin_activation(plugin_id: &str, capability_plugin_id: &str) -> Value {
json!({
"schemaVersion": PLUGIN_ACTIVATION_SCHEMA_VERSION,
"pluginId": plugin_id,
"version": "1.0.0",
"contentDigest": format!("sha256:{plugin_id}"),
"marketplaceId": "test-marketplace",
"packageSourceUri": format!("/plugins/{plugin_id}"),
"runtimeCapabilities": {
"schemaVersion": PLUGIN_RUNTIME_CAPABILITY_SCHEMA_VERSION,
"pluginId": capability_plugin_id,
"version": "1.0.0",
"packageSourceUri": format!("/plugins/{plugin_id}"),
"skills": [],
"mcpBindings": [],
}
})
}
}

Some files were not shown because too many files have changed in this diff Show More