diff --git a/packages/app/src/desktop/daemon/daemon-management-toggle.test.ts b/packages/app/src/desktop/daemon/daemon-management-toggle.test.ts index 831e40f25f..e794499f63 100644 --- a/packages/app/src/desktop/daemon/daemon-management-toggle.test.ts +++ b/packages/app/src/desktop/daemon/daemon-management-toggle.test.ts @@ -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 () => { diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index 87cc058a0c..1785b716d8 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -186,6 +186,28 @@ export async function getDesktopDaemonStatus(): Promise { return parseDesktopDaemonStatus(await invokeDesktopCommand("desktop_daemon_status")); } +/** Resolve the identity even when the daemon has stopped. */ +export async function resolveDesktopDaemonServerId( + deps: { + getStatus(): Promise; + getSavedId(): Promise; + } = { + getStatus: getDesktopDaemonStatus, + getSavedId: () => invokeDesktopCommand("desktop_daemon_identity"), + }, +): Promise { + 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 { return parseDesktopDaemonStatus(await invokeDesktopCommand("start_desktop_daemon")); } diff --git a/packages/app/src/plugins/client-runtime.ts b/packages/app/src/plugins/client-runtime.ts index 4ad402cd34..2bd93a1f43 100644 --- a/packages/app/src/plugins/client-runtime.ts +++ b/packages/app/src/plugins/client-runtime.ts @@ -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) { diff --git a/packages/app/src/plugins/hosts/index.test.ts b/packages/app/src/plugins/hosts/index.test.ts index 4d438275da..d2643da098 100644 --- a/packages/app/src/plugins/hosts/index.test.ts +++ b/packages/app/src/plugins/hosts/index.test.ts @@ -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") }); diff --git a/packages/app/src/plugins/hosts/index.ts b/packages/app/src/plugins/hosts/index.ts index 579ad303e1..84edabb906 100644 --- a/packages/app/src/plugins/hosts/index.ts +++ b/packages/app/src/plugins/hosts/index.ts @@ -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); }; diff --git a/packages/desktop/src/daemon/daemon-manager.test.ts b/packages/desktop/src/daemon/daemon-manager.test.ts index ff830666ad..9e6ff5264c 100644 --- a/packages/desktop/src/daemon/daemon-manager.test.ts +++ b/packages/desktop/src/daemon/daemon-manager.test.ts @@ -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"); diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 769b5513f5..355c25a88f 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -476,6 +476,14 @@ export function createDaemonCommandHandlers(): Record 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; diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index 5375b98c68..60357e940b 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -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): 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): 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}