fix(plugins): protect host changes from plugin failures

This commit is contained in:
Mohamed Boudra
2026-11-02 05:20:20 +01:00
parent f92cce9fd2
commit 8f9465730d
8 changed files with 163 additions and 14 deletions
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { executeDaemonManagementToggle } from "./daemon-management-toggle";
import type { DesktopDaemonStatus } from "./desktop-daemon";
import { resolveDesktopDaemonServerId, type DesktopDaemonStatus } from "./desktop-daemon";
const runningManagedStatus: DesktopDaemonStatus = {
serverId: "desktop",
@@ -54,6 +54,29 @@ function makeDeps(overrides?: {
};
}
it("retains the desktop identity when the daemon is stopped or status fails", async () => {
expect(
await resolveDesktopDaemonServerId({
getStatus: async () => ({ ...stoppedStatus, serverId: "" }),
getSavedId: async () => "desktop",
}),
).toBe("desktop");
expect(
await resolveDesktopDaemonServerId({
getStatus: async () => {
throw new Error("offline");
},
getSavedId: async () => "desktop",
}),
).toBe("desktop");
expect(
await resolveDesktopDaemonServerId({
getStatus: async () => runningManagedStatus,
getSavedId: async () => "old",
}),
).toBe("desktop");
});
describe("executeDaemonManagementToggle", () => {
describe("enable path (currentlyManaging: false)", () => {
it("persists the new setting then starts the daemon", async () => {
@@ -186,6 +186,28 @@ export async function getDesktopDaemonStatus(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand("desktop_daemon_status"));
}
/** Resolve the identity even when the daemon has stopped. */
export async function resolveDesktopDaemonServerId(
deps: {
getStatus(): Promise<DesktopDaemonStatus>;
getSavedId(): Promise<string | null>;
} = {
getStatus: getDesktopDaemonStatus,
getSavedId: () => invokeDesktopCommand<string | null>("desktop_daemon_identity"),
},
): Promise<string | null> {
try {
const id = (await deps.getStatus()).serverId.trim();
if (id) return id;
} catch {
/* The persisted identity remains available if the status request fails. */
}
const id = await deps.getSavedId();
if (id === null) return null;
if (typeof id !== "string") throw new Error("Invalid desktop daemon identity");
return id.trim() || null;
}
export async function startDesktopDaemon(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand("start_desktop_daemon"));
}
+5 -2
View File
@@ -1,6 +1,9 @@
import { createPlayAudio, type AudioEngine } from "@/audio";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { getDesktopDaemonStatus, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import {
resolveDesktopDaemonServerId,
shouldUseDesktopDaemon,
} from "@/desktop/daemon/desktop-daemon";
import { createPluginHosts } from "./hosts";
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client";
import {
@@ -32,7 +35,7 @@ export function createPluginClientRuntime(
createPluginNavigation({ serverId, workspaceId: null }).openHostSettings(serverId);
},
async isManagedLocalHost(serverId) {
return shouldUseDesktopDaemon() && (await getDesktopDaemonStatus()).serverId === serverId;
return shouldUseDesktopDaemon() && (await resolveDesktopDaemonServerId()) === serverId;
},
}),
addComposerPill(contribution) {
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { createPluginHosts, type PluginHostsSource } from "./index";
import { resolveDesktopDaemonServerId } from "@/desktop/daemon/desktop-daemon";
function registry() {
const hosts = [
@@ -154,6 +155,37 @@ describe("plugin host access", () => {
h.lifetime.abort();
});
it("isolates a throwing observer from other observers and registry mutations", async () => {
const h = registry();
let calls = 0;
h.runtime.observeHosts(() => {
if (calls++ > 0) throw new Error("plugin listener failed");
});
let delivered = 0;
h.runtime.observeHosts(() => {
delivered++;
});
await expect(h.runtime.getSnapshot()[0].removeConnection("one")).resolves.toBeUndefined();
expect(delivered).toBe(2);
expect(h.runtime.getSnapshot()[0].connections).toHaveLength(1);
h.lifetime.abort();
});
it("refuses to remove the last connection of the desktop daemon", async () => {
const h = registry();
const lifetime = new AbortController();
const runtime = createPluginHosts(h.source, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async (id) => id === "a",
});
const host = runtime.getSnapshot()[0];
await host.removeConnection("one");
await expect(host.removeConnection("two")).rejects.toThrow("desktop daemon");
expect(host.connections).toHaveLength(1);
lifetime.abort();
h.lifetime.abort();
});
it("returns the projected handle after adding a host", async () => {
const h = registry();
h.source.addHost = async () => ({ status: "saved", serverId: "a" });
@@ -233,6 +265,26 @@ describe("plugin host access", () => {
h.lifetime.abort();
});
it("protects the desktop host after its daemon has stopped", async () => {
const h = registry();
const lifetime = new AbortController();
const runtime = createPluginHosts(h.source, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async (id) =>
id ===
(await resolveDesktopDaemonServerId({
getStatus: async () => {
throw new Error("daemon stopped");
},
getSavedId: async () => "a",
})),
});
await expect(runtime.getSnapshot()[0].remove()).rejects.toThrow("desktop daemon");
expect(runtime.getSnapshot()).toHaveLength(2);
lifetime.abort();
h.lifetime.abort();
});
it("rejects unknown and disconnected targets without falling through to another host", () => {
const h = registry();
h.snapshots.set("a", { connectionStatus: "online", client: connection("a") });
+18 -2
View File
@@ -259,6 +259,16 @@ export function createPluginHosts(
async removeConnection(connectionId) {
await perform(id, async () => {
assertHost(id);
const isManaged = await options.isManagedLocalHost(id);
assertHost(id);
const host = source.getHosts().find((candidate) => candidate.serverId === id)!;
if (
isManaged &&
host.connections.length === 1 &&
host.connections[0].id === connectionId
) {
throw new Error("Cannot remove the desktop daemon host");
}
await source.removeConnection(id, connectionId);
});
},
@@ -346,9 +356,15 @@ export function createPluginHosts(
},
observeHosts(listener: (hosts: readonly PluginHost[]) => void) {
if (signal.aborted) throw new Error("Plugin has stopped");
const notify = () => listener(snapshot);
const notify = () => {
try {
listener(snapshot);
} catch (error) {
console.error("[Plugins] Host observer failed", error);
}
};
listeners.add(notify);
listener(snapshot);
notify();
return () => {
listeners.delete(notify);
};
@@ -152,6 +152,12 @@ describe("daemon-manager commands", () => {
expect(mocks.runExternalCliJsonCommand).not.toHaveBeenCalled();
});
it("reads the desktop daemon identity after it has stopped", async () => {
mkdirSync(mocks.paseoHome);
writeFileSync(path.join(mocks.paseoHome, "server-id"), "srv_existing\n");
expect(await createDaemonCommandHandlers().desktop_daemon_identity()).toBe("srv_existing");
});
it("reports an errored daemon when the local daemon state cannot be read", async () => {
mkdirSync(mocks.paseoHome);
writeFileSync(path.join(mocks.paseoHome, "paseo.pid"), "garbage");
@@ -476,6 +476,14 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
runningUnderARM64Translation: isRunningUnderARM64Translation(),
}),
desktop_daemon_status: () => resolveDesktopDaemonStatus(),
desktop_daemon_identity: () => {
try {
return readFileSync(path.join(getPaseoHome(), "server-id"), "utf8").trim() || null;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
},
desktop_local_credential: async (args) => {
const instance = await readDaemonInstance(getPaseoHome());
if (!instance?.desktopManaged || typeof args?.listen !== "string") return null;
+28 -9
View File
@@ -1779,7 +1779,7 @@ function PullRequestAction({ theme }: PluginSurfaceProps) {
返回的 API 涵盖项目、工作区、Agent、终端、Provider 和 daemon 配置。其方法请参阅 [SDK API 参考](/docs/sdk/reference)。这里刻意没有连接生命周期方法,因为连接由 Paseo 负责。
### 发现主机并指定另一台主机 {#discover-hosts-and-target-another-host}
### 主机 {#hosts}
使用 `useHosts()` 显示已配置的主机,并在操作回调中使用 `getPaseoClient(serverId)` 对其中一台主机
执行 SDK 操作:
@@ -1825,13 +1825,33 @@ export function HostAgents({ theme }: Pick<PluginSurfaceProps, "theme">): ReactE
}
```
`useHosts(): readonly PluginHostSummary[]` 包括离线的主机,并在主机、标签或状态变化时更新。
`useHosts(): readonly PluginHost[]` 包括离线主机,并在可见字段变化时更新。`observeHosts(listener)`
立即提供相同的列表,并返回取消订阅函数。请将清单中的 `requirements.paseo` 设为提供这些主机能力的发布版本。
| 摘要字段 | 类型或取值 | 含义 |
| ---------- | ------------------------------------------------------------ | --------------------------------------------- |
| `serverId` | `string` | 传给 `getPaseoClient` 的 ID。 |
| `label` | `string` | 主机的显示名称。 |
| `status` | `"idle"`、`"connecting"`、`"online"`、`"offline"`、`"error"` | 当前应用的连接状态。SDK 调用需要 `"online"`。 |
| 主机字段 | 含义 |
| ---------------------------------------- | -------------------------------------------------------------------------------------- |
| `serverId` | 传给 `getPaseoClient` 的 ID。 |
| `label` | 显示名称。 |
| `status` | `idle`、`connecting`、`online`、`offline` 或 `error`。SDK 调用需要 `online`。 |
| `appearance` | `color` 和 `badgeDisplay`;`null` 使用本地主机或远程主机的默认显示方式。 |
| `connections` | 已保存的连接,包括 ID、类型、不含凭据的 `uri` 和类型专属设置。路由连接的 URI 为 null。 |
| `activeConnectionId` | 活动连接的 ID,或 null。 |
| `error` | 当前连接错误,或 null。 |
| `authFailureReason` | `password_required`、`incorrect_password` 或 null。 |
| `lastOnlineAt`、`createdAt`、`updatedAt` | ISO 时间戳;`lastOnlineAt` 可以为 null。 |
调用 `host.update({ label, appearance })`、`host.removeConnection(id)`、`host.remove()` 或
`host.openSettings()` 管理主机。将 `appearance.badgeDisplay` 更新为 null 可恢复自动默认值。
移除最后一个连接会移除主机。插件不能移除桌面 daemon 主机。
使用 `addHost({ connection, label? })` 和[共享连接语法](../cli.md#connected-hosts)。连接字符串可以包含
`?password=`。密码用于探测并保存在本地,插件无法读回密码。中继目标和配对链接需要用户确认。
| 结果 | 含义 |
| ----------------------------------------- | ------------------------------------------------------------------------------------ |
| `{ status: "saved", host }` | 主机或连接已保存。 |
| `{ status: "password_required", reason }` | 未保存任何内容;提供密码后重试。原因是 `password_required` 或 `incorrect_password`。 |
| `{ status: "cancelled" }` | 用户拒绝了确认。 |
`getPaseoClient(serverId: string): PaseoApi` 借用该主机已认证的应用连接。请在客户端入口代码或回调中
调用它;它不会打开 socket,也不要求目标 daemon 上安装了该插件。在执行操作时再获取 API,以便使用当前的
@@ -1849,8 +1869,7 @@ export function HostAgents({ theme }: Pick<PluginSurfaceProps, "theme">): ReactE
你也可以通过普通的 SDK API 释放单个订阅。
插件是受信任的应用代码;跨主机访问是有意设计的。摘要中不包含连接 URL 或凭据,借用的 API 也不提供连接
生命周期控制。请参阅[主机 Agent 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/hosts)。
插件可以查看连接目标和 daemon 身份,但不能读取已保存的密码。借用的 API 不提供连接生命周期控制。请参阅[主机 Agent 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/hosts)。
## 添加插件特有的后端行为 {#add-plugin-specific-backend-behavior}