mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 14:43:01 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e050bbef4b | ||
|
|
af0b7a39a8 |
+3
-3
@@ -374,10 +374,10 @@ changes both `usePaseo()` and `useRpc()` to that host's installation. An offline
|
||||
installation. A server handler owns an IPC-backed daemon session for the life of its subprocess.
|
||||
Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation.
|
||||
|
||||
Host handles and host-targeted clients are owned by `packages/app/src/plugins/hosts`. Its `projectHost` is the secret boundary; host target input uses the shared `parseHostTarget` grammar. Per-installation
|
||||
Host-targeted clients and discovery are owned by `packages/app/src/plugins/hosts`, with per-installation
|
||||
bindings supplied by the bundle loader. Bind the imperative getter to that installation; do not
|
||||
resolve ownership through a mutable current-plugin global. Keep observation ownership in this module
|
||||
and transport ownership in the app host runtime. See the [public host API contract](../public-docs/plugins/reference.md#hosts).
|
||||
and transport ownership in the app host runtime. See the [public host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
|
||||
|
||||
Each subprocess gets an exclusively owned `plugin:<id>` session. That identity is reserved from
|
||||
normal clients, never resumes another session, and is cleaned immediately on exit without reconnect
|
||||
@@ -396,7 +396,7 @@ in-memory bundles usable, and a later catalog retries persistence.
|
||||
|
||||
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
|
||||
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
|
||||
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#hosts).
|
||||
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
|
||||
|
||||
Workspace panels, Command Center items, and client slash commands are client contributions. The
|
||||
daemon transports their compiled bundle without interpreting placement or callbacks. Panel props
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { expect, it } from "vitest";
|
||||
import { HostPickerOption } from "./host-picker";
|
||||
|
||||
const actions: string[] = [];
|
||||
function select() {
|
||||
actions.push("select");
|
||||
}
|
||||
function openSettings() {
|
||||
actions.push("settings");
|
||||
}
|
||||
|
||||
it("keeps host selection and settings as separate accessible buttons", () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
actions.length = 0;
|
||||
act(() =>
|
||||
root.render(
|
||||
<HostPickerOption
|
||||
serverId="host-a"
|
||||
label="Host A"
|
||||
showActiveConnection={false}
|
||||
active={false}
|
||||
onPress={select}
|
||||
onOpenHostSettings={openSettings}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
const host = container.querySelector('button[aria-label="Host A"]');
|
||||
const settings = container.querySelector('button[aria-label="Open Host A settings"]');
|
||||
expect(host).not.toBeNull();
|
||||
expect(settings).not.toBeNull();
|
||||
expect(host?.contains(settings!)).toBe(false);
|
||||
act(() => (settings as HTMLElement).click());
|
||||
expect(actions).toEqual(["settings"]);
|
||||
act(() => (host as HTMLElement).click());
|
||||
expect(actions).toEqual(["settings", "select"]);
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/comb
|
||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||
import { useHostRuntimeSnapshot, type ActiveConnection } from "@/runtime/host-runtime";
|
||||
import { orderHostsLocalFirst } from "@/types/host-connection";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import {
|
||||
ADD_HOST_OPTION_ID,
|
||||
ALL_HOSTS_OPTION_ID,
|
||||
@@ -90,33 +89,35 @@ export function HostPickerOption({
|
||||
);
|
||||
const trailingSlot = useMemo(() => {
|
||||
if (!onOpenHostSettings) return undefined;
|
||||
return <View style={styles.settingsSlot} />;
|
||||
}, [onOpenHostSettings]);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handleSettingsPress}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open ${label} settings`}
|
||||
>
|
||||
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
}, [
|
||||
handleSettingsPress,
|
||||
label,
|
||||
onOpenHostSettings,
|
||||
theme.colors.foregroundMuted,
|
||||
theme.iconSize.sm,
|
||||
]);
|
||||
|
||||
return (
|
||||
<View style={styles.optionContainer}>
|
||||
<ComboboxItem
|
||||
label={label}
|
||||
description={connectionLabel}
|
||||
leadingSlot={leadingSlot}
|
||||
trailingSlot={trailingSlot}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
/>
|
||||
{onOpenHostSettings ? (
|
||||
<Pressable
|
||||
style={styles.settingsAction}
|
||||
onPress={handleSettingsPress}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open ${label} settings`}
|
||||
>
|
||||
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
<ComboboxItem
|
||||
label={label}
|
||||
description={connectionLabel}
|
||||
leadingSlot={leadingSlot}
|
||||
trailingSlot={trailingSlot}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -318,15 +319,6 @@ export function HostPicker({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
optionContainer: { position: "relative" },
|
||||
settingsSlot: { width: theme.iconSize.sm, height: theme.iconSize.sm },
|
||||
settingsAction: {
|
||||
position: "absolute",
|
||||
right: theme.spacing[3] + (isWeb ? 0 : theme.spacing[1]),
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
justifyContent: "center",
|
||||
},
|
||||
statusDotSlot: {
|
||||
width: theme.iconSize.sm,
|
||||
height: theme.iconSize.sm,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { executeDaemonManagementToggle } from "./daemon-management-toggle";
|
||||
import { resolveDesktopDaemonServerId, type DesktopDaemonStatus } from "./desktop-daemon";
|
||||
import type { DesktopDaemonStatus } from "./desktop-daemon";
|
||||
|
||||
const runningManagedStatus: DesktopDaemonStatus = {
|
||||
serverId: "desktop",
|
||||
@@ -54,29 +54,6 @@ 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,28 +186,6 @@ 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"));
|
||||
}
|
||||
|
||||
@@ -132,19 +132,12 @@ function harness() {
|
||||
return {
|
||||
hosts: createPluginHosts(
|
||||
{
|
||||
addHost: async () => ({ status: "cancelled" }),
|
||||
getHosts: () => [],
|
||||
getSnapshot: () => null,
|
||||
subscribeAll: () => () => {},
|
||||
subscribeHostList: () => () => {},
|
||||
renameHost: async () => {},
|
||||
setHostColor: async () => {},
|
||||
setHostBadgeDisplay: async () => {},
|
||||
removeHost: async () => {},
|
||||
removeConnection: async () => {},
|
||||
},
|
||||
installation.lifetime.signal,
|
||||
{ openSettings() {}, isManagedLocalHost: async () => false },
|
||||
),
|
||||
paseo: installation.paseo,
|
||||
i18n: installation.i18n,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { createPlayAudio, type AudioEngine } from "@/audio";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import {
|
||||
resolveDesktopDaemonServerId,
|
||||
shouldUseDesktopDaemon,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { createPluginHosts } from "./hosts";
|
||||
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client";
|
||||
import {
|
||||
@@ -30,14 +26,7 @@ export function createPluginClientRuntime(
|
||||
...capabilities,
|
||||
i18n: installation.i18n,
|
||||
playAudio: createPlayAudio(audio, installation.lifetime.signal),
|
||||
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal, {
|
||||
openSettings(serverId) {
|
||||
createPluginNavigation({ serverId, workspaceId: null }).openHostSettings(serverId);
|
||||
},
|
||||
async isManagedLocalHost(serverId) {
|
||||
return shouldUseDesktopDaemon() && (await resolveDesktopDaemonServerId()) === serverId;
|
||||
},
|
||||
}),
|
||||
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal),
|
||||
addComposerPill(contribution) {
|
||||
return pluginButtonStore.addComposerPill(installation, contribution);
|
||||
},
|
||||
|
||||
@@ -819,14 +819,6 @@ it("binds imported getters to each originating installation across delayed callb
|
||||
hosts: {
|
||||
getSnapshot: () => [],
|
||||
subscribe: () => () => {},
|
||||
observeHosts: () => {
|
||||
calls.push(`${installation}/observe`);
|
||||
return () => {};
|
||||
},
|
||||
addHost: async (input) => {
|
||||
calls.push(`${installation}/add:${input.connection}`);
|
||||
return { status: "cancelled" };
|
||||
},
|
||||
getPaseoClient(serverId) {
|
||||
calls.push(`${installation}/${serverId}`);
|
||||
return runtime.paseo;
|
||||
@@ -834,12 +826,11 @@ it("binds imported getters to each originating installation across delayed callb
|
||||
},
|
||||
});
|
||||
const source = bundle(`
|
||||
const { getPaseoClient, observeHosts, addHost } = require("@getpaseo/plugin/client");
|
||||
const { getPaseoClient } = require("@getpaseo/plugin/client");
|
||||
getPaseoClient("entry-host");
|
||||
observeHosts(() => {});
|
||||
plugin.addCommandCenterItem({
|
||||
id: "read", title: "Read", icon: "Server", context: "global",
|
||||
onSelect: async () => { await Promise.resolve(); getPaseoClient("target-host"); await addHost({ connection: "example:6767" }); },
|
||||
onSelect: async () => { await Promise.resolve(); getPaseoClient("target-host"); },
|
||||
});
|
||||
`);
|
||||
const first = runPluginClientBundle("same-id", source, hostRuntime("first"));
|
||||
@@ -849,13 +840,9 @@ it("binds imported getters to each originating installation across delayed callb
|
||||
await second.commandCenterItems[0].onSelect({} as never);
|
||||
expect(calls).toEqual([
|
||||
"first/entry-host",
|
||||
"first/observe",
|
||||
"second/entry-host",
|
||||
"second/observe",
|
||||
"first/target-host",
|
||||
"first/add:example:6767",
|
||||
"second/target-host",
|
||||
"second/add:example:6767",
|
||||
]);
|
||||
await first.cleanup();
|
||||
await second.cleanup();
|
||||
|
||||
@@ -520,10 +520,6 @@ export function runPluginClientBundle(
|
||||
useSettings,
|
||||
openExternalUrl,
|
||||
getPaseoClient: (serverId: string) => runtime.hosts.getPaseoClient(serverId),
|
||||
addHost: (input: Parameters<typeof runtime.hosts.addHost>[0]) =>
|
||||
runtime.hosts.addHost(input),
|
||||
observeHosts: (listener: Parameters<typeof runtime.hosts.observeHosts>[0]) =>
|
||||
runtime.hosts.observeHosts(listener),
|
||||
useHosts: () =>
|
||||
React.useSyncExternalStore(
|
||||
runtime.hosts.subscribe,
|
||||
|
||||
@@ -1,66 +1,17 @@
|
||||
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 = [
|
||||
{
|
||||
serverId: "a",
|
||||
label: "Alpha",
|
||||
password: "secret",
|
||||
appearance: { color: "none" as const, badgeDisplay: null },
|
||||
connections: [
|
||||
{ id: "one", type: "directTcp" as const, endpoint: "localhost:6767", useTls: false },
|
||||
{ id: "two", type: "directSocket" as const, path: "/tmp/test" },
|
||||
],
|
||||
lifecycle: {},
|
||||
preferredConnectionId: null,
|
||||
createdAt: "today",
|
||||
updatedAt: "today",
|
||||
},
|
||||
{
|
||||
serverId: "b",
|
||||
label: "Beta",
|
||||
password: "secret",
|
||||
appearance: { color: "none" as const, badgeDisplay: null },
|
||||
connections: [],
|
||||
lifecycle: {},
|
||||
preferredConnectionId: null,
|
||||
createdAt: "today",
|
||||
updatedAt: "today",
|
||||
},
|
||||
{ serverId: "a", label: "Alpha", password: "secret" },
|
||||
{ serverId: "b", label: "Beta", password: "secret" },
|
||||
];
|
||||
const snapshots = new Map<
|
||||
string,
|
||||
{ connectionStatus: "online" | "offline" | "connecting"; client: DaemonClient | null }
|
||||
>();
|
||||
const snapshots = new Map<string, NonNullable<ReturnType<PluginHostsSource["getSnapshot"]>>>();
|
||||
const listeners = new Set<() => void>();
|
||||
const source: PluginHostsSource = {
|
||||
addHost: async () => ({ status: "cancelled" }),
|
||||
getHosts: () => hosts,
|
||||
getSnapshot: (id) => {
|
||||
const value = snapshots.get(id);
|
||||
return value
|
||||
? { ...value, activeConnectionId: null, lastError: null, lastOnlineAt: null }
|
||||
: null;
|
||||
},
|
||||
renameHost: async (id, label) => {
|
||||
hosts.find((candidate) => candidate.serverId === id)!.label = label;
|
||||
},
|
||||
setHostColor: async () => {},
|
||||
setHostBadgeDisplay: async () => {},
|
||||
removeHost: async (id) => {
|
||||
const index = hosts.findIndex((host) => host.serverId === id);
|
||||
if (index >= 0) hosts.splice(index, 1);
|
||||
for (const l of listeners) l();
|
||||
},
|
||||
removeConnection: async (id, connectionId) => {
|
||||
const host = hosts.find((candidate) => candidate.serverId === id)!;
|
||||
host.connections = host.connections.filter((c) => c.id !== connectionId);
|
||||
if (!host.connections.length) await source.removeHost(id);
|
||||
else for (const l of listeners) l();
|
||||
},
|
||||
getSnapshot: (id) => snapshots.get(id) ?? null,
|
||||
subscribeAll(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
@@ -75,10 +26,7 @@ function registry() {
|
||||
},
|
||||
};
|
||||
const lifetime = new AbortController();
|
||||
const runtime = createPluginHosts(source, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
const runtime = createPluginHosts(source, lifetime.signal);
|
||||
return {
|
||||
hosts,
|
||||
snapshots,
|
||||
@@ -100,7 +48,7 @@ function connection(id: string) {
|
||||
describe("plugin host access", () => {
|
||||
it("publishes credential-free configured hosts and only changes snapshots when summaries change", () => {
|
||||
const h = registry();
|
||||
expect(h.runtime.getSnapshot()).toMatchObject([
|
||||
expect(h.runtime.getSnapshot()).toEqual([
|
||||
{ serverId: "a", label: "Alpha", status: "offline" },
|
||||
{ serverId: "b", label: "Beta", status: "offline" },
|
||||
]);
|
||||
@@ -120,171 +68,6 @@ describe("plugin host access", () => {
|
||||
expect(h.listeners.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps handles stable, protects credentials, and stops observations on unload", async () => {
|
||||
const h = registry();
|
||||
const seen: unknown[] = [];
|
||||
const stop = h.runtime.observeHosts((hosts) => seen.push(hosts));
|
||||
const first = h.runtime.getSnapshot()[0];
|
||||
function walk(value: unknown): void {
|
||||
if (typeof value === "string") expect(value).not.toContain("secret");
|
||||
if (!value || typeof value !== "object") return;
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
expect(key.toLowerCase()).not.toBe("password");
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
walk(h.runtime.getSnapshot());
|
||||
h.hosts[0].label = "New name";
|
||||
h.publish();
|
||||
expect(h.runtime.getSnapshot()[0]).toBe(first);
|
||||
expect(first.label).toBe("New name");
|
||||
expect(seen).toHaveLength(2);
|
||||
h.lifetime.abort();
|
||||
h.publish();
|
||||
expect(seen).toHaveLength(2);
|
||||
await expect(first.remove()).rejects.toThrow("Plugin has stopped");
|
||||
stop();
|
||||
});
|
||||
|
||||
it("redacts saved passwords from mutation errors", async () => {
|
||||
const h = registry();
|
||||
h.source.renameHost = async () => {
|
||||
throw new Error("Failed for secret");
|
||||
};
|
||||
await expect(h.runtime.getSnapshot()[0].update({ label: "New" })).rejects.not.toThrow("secret");
|
||||
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" });
|
||||
const result = await h.runtime.addHost({ connection: "tcp://example:6767?password=secret" });
|
||||
expect(result.status).toBe("saved");
|
||||
if (result.status !== "saved") throw new Error("expected saved host");
|
||||
expect(result.host).toBe(h.runtime.getSnapshot()[0]);
|
||||
expect(JSON.stringify(result)).not.toContain("secret");
|
||||
h.lifetime.abort();
|
||||
await expect(h.runtime.addHost({ connection: "example:6767" })).rejects.toThrow(
|
||||
"Plugin has stopped",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes cancellation and authentication results without exposing credentials", async () => {
|
||||
const h = registry();
|
||||
for (const result of [
|
||||
{ status: "cancelled" as const },
|
||||
{ status: "password_required" as const, reason: "incorrect_password" as const },
|
||||
]) {
|
||||
h.source.addHost = async () => result;
|
||||
const returned = await h.runtime.addHost({
|
||||
connection: "tcp://example:6767?password=secret",
|
||||
});
|
||||
expect(returned).toEqual(result);
|
||||
for (const [key, value] of Object.entries(returned)) {
|
||||
expect(key).not.toBe("password");
|
||||
expect(value).not.toBe("secret");
|
||||
}
|
||||
}
|
||||
h.lifetime.abort();
|
||||
});
|
||||
|
||||
it("redacts encoded saved passwords in mutation errors", async () => {
|
||||
const h = registry();
|
||||
h.hosts[0].password = "two words";
|
||||
h.source.renameHost = async () => {
|
||||
throw new Error("failed for two%20words");
|
||||
};
|
||||
await expect(h.runtime.getSnapshot()[0].update({ label: "New" })).rejects.not.toThrow(
|
||||
"two%20words",
|
||||
);
|
||||
h.lifetime.abort();
|
||||
});
|
||||
|
||||
it("never exposes supplied passwords in add errors", async () => {
|
||||
const h = registry();
|
||||
h.source.addHost = async () => {
|
||||
throw new Error("secret was rejected");
|
||||
};
|
||||
await expect(
|
||||
h.runtime.addHost({ connection: "tcp://example:6767?password=secret" }),
|
||||
).rejects.not.toThrow("secret");
|
||||
h.lifetime.abort();
|
||||
});
|
||||
|
||||
it("removes a connection and then the last host connection", async () => {
|
||||
const h = registry();
|
||||
const host = h.runtime.getSnapshot()[0];
|
||||
await host.removeConnection("one");
|
||||
expect(host.connections.map((item) => item.id)).toEqual(["two"]);
|
||||
await host.removeConnection("two");
|
||||
expect(h.runtime.getSnapshot().map((entry) => entry.serverId)).toEqual(["b"]);
|
||||
h.lifetime.abort();
|
||||
});
|
||||
|
||||
it("rejects removal of the managed desktop daemon", async () => {
|
||||
const h = registry();
|
||||
const lifetime = new AbortController();
|
||||
const runtime = createPluginHosts(h.source, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async (id) => id === "a",
|
||||
});
|
||||
await expect(runtime.getSnapshot()[0].remove()).rejects.toThrow("desktop daemon");
|
||||
expect(runtime.getSnapshot()).toHaveLength(2);
|
||||
lifetime.abort();
|
||||
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") });
|
||||
@@ -299,10 +82,7 @@ describe("plugin host access", () => {
|
||||
h.snapshots.set("b", { connectionStatus: "online", client });
|
||||
h.publish();
|
||||
const otherLifetime = new AbortController();
|
||||
const other = createPluginHosts(h.source, otherLifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
const other = createPluginHosts(h.source, otherLifetime.signal);
|
||||
const api = h.runtime.getPaseoClient("b");
|
||||
expect(api).toBe(h.runtime.getPaseoClient("b"));
|
||||
expect(api).not.toBe(other.getPaseoClient("b"));
|
||||
|
||||
@@ -1,288 +1,31 @@
|
||||
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type {
|
||||
PluginHost,
|
||||
PluginHostConnection,
|
||||
PluginHostPatch,
|
||||
PluginHostAddInput,
|
||||
PluginHostAddResult,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import {
|
||||
parseHostPort,
|
||||
serializeConnectionUri,
|
||||
serializeRelayConnectionUri,
|
||||
shouldUseTlsForDefaultHostedRelay,
|
||||
} from "@getpaseo/protocol/daemon-endpoints";
|
||||
import { parseHostTarget, serializeSocketHostTarget } from "@getpaseo/protocol/host-target";
|
||||
import {
|
||||
DEFAULT_SSH_DAEMON_PORT,
|
||||
serializeSshTransportUri,
|
||||
} from "@getpaseo/protocol/ssh-transport";
|
||||
import type { PluginHostSummary } from "@getpaseo/plugin/client";
|
||||
|
||||
type HostSnapshot = Pick<
|
||||
HostRuntimeSnapshot,
|
||||
| "connectionStatus"
|
||||
| "client"
|
||||
| "activeConnectionId"
|
||||
| "lastError"
|
||||
| "authFailureReason"
|
||||
| "lastOnlineAt"
|
||||
>;
|
||||
export interface PluginHostsSource {
|
||||
addHost(
|
||||
input: PluginHostAddInput,
|
||||
signal?: AbortSignal,
|
||||
): Promise<
|
||||
| { status: "saved"; serverId: string }
|
||||
| { status: "password_required"; reason: "password_required" | "incorrect_password" }
|
||||
| { status: "cancelled" }
|
||||
>;
|
||||
getHosts(): readonly HostProfile[];
|
||||
getSnapshot(serverId: string): HostSnapshot | null;
|
||||
getHosts(): readonly { serverId: string; label: string }[];
|
||||
getSnapshot(serverId: string): {
|
||||
connectionStatus: PluginHostSummary["status"];
|
||||
client: DaemonClient | null;
|
||||
} | null;
|
||||
subscribeAll(listener: () => void): () => void;
|
||||
subscribeHostList(listener: () => void): () => void;
|
||||
renameHost(serverId: string, label: string): Promise<void>;
|
||||
setHostColor(serverId: string, color: PluginHost["appearance"]["color"]): Promise<void>;
|
||||
setHostBadgeDisplay(
|
||||
serverId: string,
|
||||
badge: PluginHost["appearance"]["badgeDisplay"],
|
||||
): Promise<void>;
|
||||
removeHost(serverId: string): Promise<void>;
|
||||
removeConnection(serverId: string, connectionId: string): Promise<void>;
|
||||
}
|
||||
function redactSecret(message: string, secret: string | undefined): string {
|
||||
if (!secret) return message;
|
||||
for (const value of [
|
||||
secret,
|
||||
encodeURIComponent(secret),
|
||||
new URLSearchParams({ password: secret }).toString().slice(9),
|
||||
]) {
|
||||
message = message.split(value).join("[redacted]");
|
||||
}
|
||||
return message;
|
||||
}
|
||||
type ProjectedHost = Omit<PluginHost, "update" | "remove" | "removeConnection" | "openSettings">;
|
||||
function projectHost(profile: HostProfile, snapshot: HostSnapshot | null): ProjectedHost {
|
||||
const connections: PluginHostConnection[] = profile.connections.map((connection) => {
|
||||
switch (connection.type) {
|
||||
case "directTcp":
|
||||
return {
|
||||
id: connection.id,
|
||||
type: connection.type,
|
||||
endpoint: connection.endpoint,
|
||||
useTls: connection.useTls ?? false,
|
||||
uri: serializeConnectionUri({
|
||||
...parseHostPort(connection.endpoint),
|
||||
useTls: connection.useTls ?? false,
|
||||
}),
|
||||
};
|
||||
case "directSocket":
|
||||
return {
|
||||
id: connection.id,
|
||||
type: connection.type,
|
||||
path: connection.path,
|
||||
uri: serializeSocketHostTarget("unix", connection.path),
|
||||
};
|
||||
case "directPipe":
|
||||
return {
|
||||
id: connection.id,
|
||||
type: connection.type,
|
||||
path: connection.path,
|
||||
uri: serializeSocketHostTarget("pipe", connection.path),
|
||||
};
|
||||
case "remoteSsh":
|
||||
return {
|
||||
id: connection.id,
|
||||
type: connection.type,
|
||||
host: connection.host,
|
||||
...(connection.sshPort === undefined ? {} : { sshPort: connection.sshPort }),
|
||||
...(connection.daemonPort === undefined ? {} : { daemonPort: connection.daemonPort }),
|
||||
uri: serializeSshTransportUri({
|
||||
host: connection.host,
|
||||
sshPort: connection.sshPort,
|
||||
daemonPort: connection.daemonPort ?? DEFAULT_SSH_DAEMON_PORT,
|
||||
}),
|
||||
};
|
||||
case "relay": {
|
||||
const useTls =
|
||||
connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint);
|
||||
return {
|
||||
id: connection.id,
|
||||
type: connection.type,
|
||||
relayEndpoint: connection.relayEndpoint,
|
||||
useTls,
|
||||
daemonPublicKeyB64: connection.daemonPublicKeyB64,
|
||||
...(connection.minimumSecurityMode
|
||||
? { minimumSecurityMode: connection.minimumSecurityMode }
|
||||
: {}),
|
||||
uri: serializeRelayConnectionUri({
|
||||
offer: {
|
||||
v: 2,
|
||||
serverId: profile.serverId,
|
||||
daemonPublicKeyB64: connection.daemonPublicKeyB64,
|
||||
relay: { endpoint: connection.relayEndpoint, useTls },
|
||||
...(connection.minimumSecurityMode
|
||||
? { minimumSecurityMode: connection.minimumSecurityMode }
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
case "routed":
|
||||
return {
|
||||
id: connection.id,
|
||||
type: connection.type,
|
||||
originServerId: connection.originServerId,
|
||||
uri: null,
|
||||
};
|
||||
}
|
||||
});
|
||||
const error = snapshot?.lastError ?? null;
|
||||
return {
|
||||
serverId: profile.serverId,
|
||||
label: profile.label,
|
||||
status: snapshot?.connectionStatus ?? "offline",
|
||||
appearance: { color: profile.appearance.color, badgeDisplay: profile.appearance.badgeDisplay },
|
||||
connections,
|
||||
activeConnectionId: snapshot?.activeConnectionId ?? null,
|
||||
error: error ? redactSecret(error, profile.password) : null,
|
||||
authFailureReason: snapshot?.authFailureReason ?? null,
|
||||
lastOnlineAt: snapshot?.lastOnlineAt ?? null,
|
||||
createdAt: profile.createdAt,
|
||||
updatedAt: profile.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Each evaluated installation owns its borrowed APIs and registry subscriptions. */
|
||||
export function createPluginHosts(
|
||||
source: PluginHostsSource,
|
||||
signal: AbortSignal,
|
||||
options: {
|
||||
openSettings(serverId: string): void;
|
||||
isManagedLocalHost(serverId: string): Promise<boolean>;
|
||||
},
|
||||
) {
|
||||
export function createPluginHosts(source: PluginHostsSource, signal: AbortSignal) {
|
||||
const clients = new Map<
|
||||
string,
|
||||
{ client: DaemonClient; api: PaseoApi; lifetime: AbortController }
|
||||
>();
|
||||
const listeners = new Set<() => void>();
|
||||
let snapshot: readonly PluginHost[] = [];
|
||||
let serialized = "";
|
||||
const entries = new Map<string, { data: ProjectedHost; handle: PluginHost }>();
|
||||
function assertHost(id: string) {
|
||||
if (signal.aborted) throw new Error("Plugin has stopped");
|
||||
if (!source.getHosts().some((host) => host.serverId === id))
|
||||
throw new Error(`Unknown Paseo host: ${id}`);
|
||||
}
|
||||
async function perform(id: string, action: () => Promise<void>): Promise<void> {
|
||||
const secret = source.getHosts().find((host) => host.serverId === id)?.password;
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// A cause would expose the original error, including any password.
|
||||
// oxlint-disable-next-line preserve-caught-error
|
||||
throw new Error(redactSecret(message, secret));
|
||||
}
|
||||
}
|
||||
function readHosts(): { hosts: PluginHost[]; key: string } {
|
||||
const projected = source
|
||||
.getHosts()
|
||||
.map((profile) => projectHost(profile, source.getSnapshot(profile.serverId)));
|
||||
const ids = new Set(projected.map((host) => host.serverId));
|
||||
for (const id of entries.keys()) if (!ids.has(id)) entries.delete(id);
|
||||
const hosts = projected.map((data) => {
|
||||
let entry = entries.get(data.serverId);
|
||||
if (!entry) {
|
||||
const id = data.serverId;
|
||||
const read = () => entry!.data;
|
||||
const handle: PluginHost = {
|
||||
get serverId() {
|
||||
return read().serverId;
|
||||
},
|
||||
get label() {
|
||||
return read().label;
|
||||
},
|
||||
get status() {
|
||||
return read().status;
|
||||
},
|
||||
get appearance() {
|
||||
return read().appearance;
|
||||
},
|
||||
get connections() {
|
||||
return read().connections;
|
||||
},
|
||||
get activeConnectionId() {
|
||||
return read().activeConnectionId;
|
||||
},
|
||||
get error() {
|
||||
return read().error;
|
||||
},
|
||||
get authFailureReason() {
|
||||
return read().authFailureReason;
|
||||
},
|
||||
get lastOnlineAt() {
|
||||
return read().lastOnlineAt;
|
||||
},
|
||||
get createdAt() {
|
||||
return read().createdAt;
|
||||
},
|
||||
get updatedAt() {
|
||||
return read().updatedAt;
|
||||
},
|
||||
async update(patch: PluginHostPatch) {
|
||||
await perform(id, async () => {
|
||||
assertHost(id);
|
||||
if (patch.label !== undefined) await source.renameHost(id, patch.label);
|
||||
if (patch.appearance?.color !== undefined) {
|
||||
assertHost(id);
|
||||
await source.setHostColor(id, patch.appearance.color);
|
||||
}
|
||||
if (patch.appearance?.badgeDisplay !== undefined) {
|
||||
assertHost(id);
|
||||
await source.setHostBadgeDisplay(id, patch.appearance.badgeDisplay);
|
||||
}
|
||||
});
|
||||
},
|
||||
async remove() {
|
||||
await perform(id, async () => {
|
||||
assertHost(id);
|
||||
if (await options.isManagedLocalHost(id))
|
||||
throw new Error("Cannot remove the desktop daemon host");
|
||||
assertHost(id);
|
||||
await source.removeHost(id);
|
||||
});
|
||||
},
|
||||
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);
|
||||
});
|
||||
},
|
||||
openSettings() {
|
||||
assertHost(id);
|
||||
options.openSettings(id);
|
||||
},
|
||||
};
|
||||
entry = { data, handle };
|
||||
entries.set(id, entry);
|
||||
} else entry.data = data;
|
||||
return entry.handle;
|
||||
});
|
||||
return { hosts, key: JSON.stringify(projected) };
|
||||
let snapshot: readonly PluginHostSummary[] = [];
|
||||
function readHosts(): readonly PluginHostSummary[] {
|
||||
return source.getHosts().map(({ serverId, label }) => ({
|
||||
serverId,
|
||||
label,
|
||||
status: source.getSnapshot(serverId)?.connectionStatus ?? "offline",
|
||||
}));
|
||||
}
|
||||
function resolve(serverId: string): DaemonClient {
|
||||
if (signal.aborted) throw new Error("Plugin has stopped");
|
||||
@@ -306,16 +49,11 @@ export function createPluginHosts(
|
||||
}
|
||||
}
|
||||
const next = readHosts();
|
||||
if (serialized === next.key) return;
|
||||
serialized = next.key;
|
||||
snapshot = next.hosts;
|
||||
if (JSON.stringify(snapshot) === JSON.stringify(next)) return;
|
||||
snapshot = next;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
{
|
||||
const initial = readHosts();
|
||||
snapshot = initial.hosts;
|
||||
serialized = initial.key;
|
||||
}
|
||||
snapshot = readHosts();
|
||||
const unsubscribeRuntime = source.subscribeAll(refresh);
|
||||
const unsubscribeHosts = source.subscribeHostList(refresh);
|
||||
function stop() {
|
||||
@@ -329,46 +67,6 @@ export function createPluginHosts(
|
||||
else signal.addEventListener("abort", stop, { once: true });
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
async addHost(input: PluginHostAddInput): Promise<PluginHostAddResult> {
|
||||
if (signal.aborted) throw new Error("Plugin has stopped");
|
||||
let result: Awaited<ReturnType<PluginHostsSource["addHost"]>>;
|
||||
try {
|
||||
result = await source.addHost(input, signal);
|
||||
} catch (error) {
|
||||
let secret: string | undefined;
|
||||
try {
|
||||
secret = parseHostTarget(input.connection).password;
|
||||
} catch {
|
||||
/* Invalid input has no parsed password. */
|
||||
}
|
||||
const message = redactSecret(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
secret,
|
||||
);
|
||||
// The original error can contain the supplied credential.
|
||||
// oxlint-disable-next-line preserve-caught-error
|
||||
throw new Error(message);
|
||||
}
|
||||
if (result.status !== "saved") return result;
|
||||
const host = snapshot.find((item) => item.serverId === result.serverId);
|
||||
if (!host) throw new Error(`Unknown Paseo host: ${result.serverId}`);
|
||||
return { status: "saved", host };
|
||||
},
|
||||
observeHosts(listener: (hosts: readonly PluginHost[]) => void) {
|
||||
if (signal.aborted) throw new Error("Plugin has stopped");
|
||||
const notify = () => {
|
||||
try {
|
||||
listener(snapshot);
|
||||
} catch (error) {
|
||||
console.error("[Plugins] Host observer failed", error);
|
||||
}
|
||||
};
|
||||
listeners.add(notify);
|
||||
notify();
|
||||
return () => {
|
||||
listeners.delete(notify);
|
||||
};
|
||||
},
|
||||
subscribe(listener: () => void) {
|
||||
if (signal.aborted) return () => {};
|
||||
listeners.add(listener);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { buildPluginSettingsRoute } from "./settings/routes";
|
||||
import { router } from "expo-router";
|
||||
import type { PluginPanelLocation } from "@getpaseo/plugin/client";
|
||||
@@ -10,7 +9,7 @@ import type { PluginNavigation } from "./actions";
|
||||
export function createPluginNavigation(input: {
|
||||
serverId: string;
|
||||
workspaceId: string | null;
|
||||
}): PluginNavigation & { openHostSettings(serverId: string): void } {
|
||||
}): PluginNavigation {
|
||||
const { serverId, workspaceId } = input;
|
||||
function placement(location: PluginPanelLocation) {
|
||||
if (location !== "explorer") return undefined;
|
||||
@@ -21,9 +20,6 @@ export function createPluginNavigation(input: {
|
||||
return { mode: "pane" as const, paneId };
|
||||
}
|
||||
return {
|
||||
openHostSettings(hostId) {
|
||||
router.push(buildSettingsHostRoute(hostId));
|
||||
},
|
||||
openSettings(pluginId, screenId) {
|
||||
router.push(buildPluginSettingsRoute(serverId, pluginId, screenId));
|
||||
},
|
||||
|
||||
@@ -43,19 +43,12 @@ function registry(
|
||||
return {
|
||||
hosts: createPluginHosts(
|
||||
{
|
||||
addHost: async () => ({ status: "cancelled" }),
|
||||
getHosts: () => [],
|
||||
getSnapshot: () => null,
|
||||
subscribeAll: () => () => {},
|
||||
subscribeHostList: () => () => {},
|
||||
renameHost: async () => {},
|
||||
setHostColor: async () => {},
|
||||
setHostBadgeDisplay: async () => {},
|
||||
removeHost: async () => {},
|
||||
removeConnection: async () => {},
|
||||
},
|
||||
installation.lifetime.signal,
|
||||
{ openSettings() {}, isManagedLocalHost: async () => false },
|
||||
),
|
||||
paseo: installation.paseo,
|
||||
i18n: installation.i18n,
|
||||
|
||||
@@ -18,19 +18,12 @@ function registry(version: string) {
|
||||
return {
|
||||
hosts: createPluginHosts(
|
||||
{
|
||||
addHost: async () => ({ status: "cancelled" }),
|
||||
getHosts: () => [],
|
||||
getSnapshot: () => null,
|
||||
subscribeAll: () => () => {},
|
||||
subscribeHostList: () => () => {},
|
||||
renameHost: async () => {},
|
||||
setHostColor: async () => {},
|
||||
setHostBadgeDisplay: async () => {},
|
||||
removeHost: async () => {},
|
||||
removeConnection: async () => {},
|
||||
},
|
||||
installation.lifetime.signal,
|
||||
{ openSettings() {}, isManagedLocalHost: async () => false },
|
||||
),
|
||||
paseo: installation.paseo,
|
||||
i18n: installation.i18n,
|
||||
|
||||
@@ -55,38 +55,24 @@ export class HostConfirmations {
|
||||
}
|
||||
|
||||
/** Resolves true without asking when the link matches a saved host exactly. */
|
||||
confirmLink(
|
||||
offer: ConnectionOffer,
|
||||
savedHosts: HostProfile[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
confirmLink(offer: ConnectionOffer, savedHosts: HostProfile[]): Promise<boolean> {
|
||||
const connection = relayConnectionFromOffer(offer);
|
||||
const savedHost = savedHosts.find((host) => host.serverId === offer.serverId);
|
||||
if (savedHost && hostHasConnection(savedHost, connection)) return Promise.resolve(true);
|
||||
if (signal?.aborted) return Promise.resolve(false);
|
||||
return this.ask(
|
||||
{
|
||||
id: this.nextRequestId++,
|
||||
serverId: offer.serverId,
|
||||
keyFingerprint: formatDaemonKeyFingerprint(connection.daemonPublicKeyB64),
|
||||
relayEndpoint: connection.relayEndpoint,
|
||||
kind: savedHost ? "changedConnection" : "newHost",
|
||||
},
|
||||
signal,
|
||||
);
|
||||
return this.ask({
|
||||
id: this.nextRequestId++,
|
||||
serverId: offer.serverId,
|
||||
keyFingerprint: formatDaemonKeyFingerprint(connection.daemonPublicKeyB64),
|
||||
relayEndpoint: connection.relayEndpoint,
|
||||
kind: savedHost ? "changedConnection" : "newHost",
|
||||
});
|
||||
}
|
||||
|
||||
private ask(request: HostConfirmationRequest, signal?: AbortSignal): Promise<boolean> {
|
||||
private ask(request: HostConfirmationRequest): Promise<boolean> {
|
||||
// One question at a time: a newer one cancels the unanswered one.
|
||||
this.pending?.resolve(false);
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const cancel = () => this.answer(request.id, false);
|
||||
const finish = (approved: boolean) => {
|
||||
signal?.removeEventListener("abort", cancel);
|
||||
resolve(approved);
|
||||
};
|
||||
this.pending = { request, resolve: finish };
|
||||
signal?.addEventListener("abort", cancel, { once: true });
|
||||
this.pending = { request, resolve };
|
||||
this.emit();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
||||
import { defaultHostAppearance, selectHostBadges } from "@/hosts/appearance";
|
||||
import { defaultHostAppearance } from "@/hosts/appearance";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
|
||||
@@ -33,7 +33,6 @@ import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
|
||||
import { subscriptionFixture } from "./subscription-fixture";
|
||||
import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential";
|
||||
import type { HostConfirmationRequest } from "./host-confirmation";
|
||||
import { createPluginHosts } from "@/plugins/hosts";
|
||||
|
||||
it("requests the managed connection credential through desktop main without a web hint", async () => {
|
||||
const requests: string[] = [];
|
||||
@@ -4779,295 +4778,3 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => {
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("adding hosts from plugin connection targets", () => {
|
||||
it("does not save an unauthenticated target and retries with the password", async () => {
|
||||
const store = createPairingStore({ password: "secret", serverIdForHost: () => "srv_direct" });
|
||||
const requests = answerHostConfirmations(store, true);
|
||||
expect(await store.addHost({ connection: "example.com:6767" })).toEqual({
|
||||
status: "password_required",
|
||||
reason: "password_required",
|
||||
});
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
expect(
|
||||
await store.addHost({ connection: "tcp://example.com:6767?password=secret", label: "Build" }),
|
||||
).toEqual({ status: "saved", serverId: "srv_direct" });
|
||||
expect(store.getHosts()[0]).toMatchObject({
|
||||
label: "Build",
|
||||
password: "secret",
|
||||
connections: [{ type: "directTcp", endpoint: "example.com:6767" }],
|
||||
});
|
||||
expect(requests).toHaveLength(0);
|
||||
await waitForHostOnline(store, "srv_direct");
|
||||
store.syncHosts([]);
|
||||
});
|
||||
it("persists the supplied password and reconnects after the app restarts", async () => {
|
||||
const storage = createMemoryHostRuntimeStorage({ "@paseo:e2e": "1" });
|
||||
const first = createPairingStore({
|
||||
storage,
|
||||
password: "secret",
|
||||
serverIdForHost: () => "srv_direct",
|
||||
});
|
||||
const lifetime = new AbortController();
|
||||
const plugin = createPluginHosts(first, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
const result = await plugin.addHost({ connection: "tcp://example.com:6767?password=secret" });
|
||||
expect(result.status).toBe("saved");
|
||||
expect(JSON.stringify(result)).not.toContain("secret");
|
||||
const persisted = JSON.parse((await storage.getItem("@paseo:daemon-registry")) ?? "[]");
|
||||
expect(persisted[0].password).toBe("secret");
|
||||
lifetime.abort();
|
||||
first.syncHosts([]);
|
||||
|
||||
const attempts: string[] = [];
|
||||
const restarted = createPairingStore({
|
||||
storage,
|
||||
password: "secret",
|
||||
connectAttempts: attempts,
|
||||
serverIdForHost: () => "srv_direct",
|
||||
});
|
||||
await restarted.boot();
|
||||
await waitForHostOnline(restarted, "srv_direct");
|
||||
expect(attempts).toContain("direct:example.com:6767");
|
||||
expect(restarted.getHosts()[0].password).toBe("secret");
|
||||
const restartedLifetime = new AbortController();
|
||||
const restartedPlugin = createPluginHosts(restarted, restartedLifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
expect(JSON.stringify(restartedPlugin.getSnapshot())).not.toContain("secret");
|
||||
restartedLifetime.abort();
|
||||
restarted.syncHosts([]);
|
||||
});
|
||||
|
||||
it("asks before saving a relay target and respects rejection", async () => {
|
||||
const uri = "relay://relay.paseo.sh:443/srv_offer?key=pk_test_offer&ssl=true";
|
||||
const rejected = createPairingStore();
|
||||
const requests = answerHostConfirmations(rejected, false);
|
||||
expect(await rejected.addHost({ connection: uri })).toEqual({ status: "cancelled" });
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(rejected.getHosts()).toHaveLength(0);
|
||||
const accepted = createPairingStore();
|
||||
const acceptedRequests = answerHostConfirmations(accepted, true);
|
||||
expect(await accepted.addHost({ connection: uri, label: "Relay" })).toEqual({
|
||||
status: "saved",
|
||||
serverId: "srv_offer",
|
||||
});
|
||||
expect(acceptedRequests).toHaveLength(1);
|
||||
expect(accepted.getHosts()[0]).toMatchObject({
|
||||
label: "Relay",
|
||||
connections: [{ type: "relay" }],
|
||||
});
|
||||
accepted.syncHosts([]);
|
||||
});
|
||||
it.each([
|
||||
["ssh://deploy@build:2222?daemonPort=7777", "remoteSsh"],
|
||||
["unix:///tmp/paseo.sock", "directSocket"],
|
||||
["pipe://mypipe", "directPipe"],
|
||||
["wss://example.com/ws", "directTcp"],
|
||||
])("probes and saves %s", async (connection, type) => {
|
||||
const store = createPairingStore({ serverIdForHost: () => "srv_target" });
|
||||
expect(await store.addHost({ connection })).toEqual({
|
||||
status: "saved",
|
||||
serverId: "srv_target",
|
||||
});
|
||||
expect(store.getHosts()[0].connections[0].type).toBe(type);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
it("shares host changes between plugin handles, host settings, and sidebar badges", async () => {
|
||||
const store = createPairingStore({
|
||||
serverIdForHost: (host) => {
|
||||
const connection = host.connections[0];
|
||||
return connection?.type === "directTcp" ? connection.endpoint : "";
|
||||
},
|
||||
});
|
||||
await store.addHost({ connection: "first:6767", label: "First" });
|
||||
await store.addHost({ connection: "second:6767", label: "Second" });
|
||||
await waitForHostOnline(store, "first:6767");
|
||||
await waitForHostOnline(store, "second:6767");
|
||||
const lifetime = new AbortController();
|
||||
const plugin = createPluginHosts(store, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
expect(
|
||||
plugin.getSnapshot().map((host) => [host.label, host.status, host.connections[0].uri]),
|
||||
).toEqual([
|
||||
["First", "online", "tcp://first:6767"],
|
||||
["Second", "online", "tcp://second:6767"],
|
||||
]);
|
||||
await plugin.getSnapshot()[0].update({ label: "Build", appearance: { color: "sky" } });
|
||||
expect(store.getHosts()[0]).toMatchObject({ label: "Build", appearance: { color: "sky" } });
|
||||
expect(
|
||||
selectHostBadges({ hosts: store.getHosts(), localServerId: null, enabled: true }).get(
|
||||
"first:6767",
|
||||
),
|
||||
).toMatchObject({ label: "Build", color: "sky" });
|
||||
lifetime.abort();
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"tcp://example.com:6767?ssl=true",
|
||||
"ssh://deploy@build:2222?daemonPort=7777",
|
||||
"unix:///tmp/paseo.sock",
|
||||
"relay://relay.paseo.sh:443/srv_offer?key=pk_test_offer&ssl=true",
|
||||
])("recreates the projected connection on another device: %s", async (connection) => {
|
||||
const first = createPairingStore({ serverIdForHost: (host) => host.serverId || "srv_target" });
|
||||
answerHostConfirmations(first, true);
|
||||
const result = await first.addHost({ connection });
|
||||
if (result.status !== "saved") throw new Error("expected saved host");
|
||||
const lifetime = new AbortController();
|
||||
const plugin = createPluginHosts(first, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
const uri = plugin.getSnapshot().find((host) => host.serverId === result.serverId)
|
||||
?.connections[0].uri;
|
||||
if (!uri) throw new Error("expected connection URI");
|
||||
const second = createPairingStore({ serverIdForHost: (host) => host.serverId || "srv_target" });
|
||||
answerHostConfirmations(second, true);
|
||||
expect(await second.addHost({ connection: uri })).toEqual({
|
||||
status: "saved",
|
||||
serverId: result.serverId,
|
||||
});
|
||||
expect(second.getHosts()[0].connections).toEqual(first.getHosts()[0].connections);
|
||||
lifetime.abort();
|
||||
first.syncHosts([]);
|
||||
second.syncHosts([]);
|
||||
});
|
||||
|
||||
it("does not save an in-flight addition after its plugin stops", async () => {
|
||||
const started = createDeferred<void>();
|
||||
const probe = createDeferred<{
|
||||
client: DaemonClient;
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
}>();
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async () => {
|
||||
started.resolve(undefined);
|
||||
return probe.promise;
|
||||
},
|
||||
getClientId: async () => "test",
|
||||
readInitialConnectionHint: () => null,
|
||||
},
|
||||
});
|
||||
const lifetime = new AbortController();
|
||||
const plugin = createPluginHosts(store, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
const adding = plugin.addHost({ connection: "example.com:6767" });
|
||||
await started.promise;
|
||||
lifetime.abort();
|
||||
probe.resolve({
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: "srv",
|
||||
hostname: null,
|
||||
});
|
||||
await expect(adding).rejects.toThrow("Plugin has stopped");
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("reports a stopped plugin when an aborted probe rejects authentication", async () => {
|
||||
const started = createDeferred<void>();
|
||||
const probe = createDeferred<{
|
||||
client: DaemonClient;
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
}>();
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async () => {
|
||||
started.resolve(undefined);
|
||||
return probe.promise;
|
||||
},
|
||||
getClientId: async () => "test",
|
||||
readInitialConnectionHint: () => null,
|
||||
},
|
||||
});
|
||||
const lifetime = new AbortController();
|
||||
const plugin = createPluginHosts(store, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
const adding = plugin.addHost({ connection: "example.com:6767" });
|
||||
await started.promise;
|
||||
lifetime.abort();
|
||||
probe.reject(new DaemonAuthenticationError("password_required"));
|
||||
await expect(adding).rejects.toThrow("Plugin has stopped");
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("dismisses an unanswered confirmation when the plugin stops", async () => {
|
||||
const store = createPairingStore();
|
||||
const lifetime = new AbortController();
|
||||
const plugin = createPluginHosts(store, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
const pending = createDeferred<void>();
|
||||
const unsubscribe = store.subscribeHostConfirmation(() => {
|
||||
if (store.getPendingHostConfirmation()) pending.resolve(undefined);
|
||||
});
|
||||
const adding = plugin.addHost({
|
||||
connection: "relay://relay.paseo.sh:443/srv_offer?key=pk_test_offer&ssl=true",
|
||||
});
|
||||
await pending.promise;
|
||||
lifetime.abort();
|
||||
await expect(adding).rejects.toThrow("Plugin has stopped");
|
||||
expect(store.getPendingHostConfirmation()).toBeNull();
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
unsubscribe();
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("returns incorrect_password without saving and redacts failed probe errors", async () => {
|
||||
let failure: "auth" | "network" = "auth";
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async () => {
|
||||
if (failure === "auth") throw new DaemonAuthenticationError("incorrect_password");
|
||||
throw new Error("connection failed for secret");
|
||||
},
|
||||
getClientId: async () => "test",
|
||||
readInitialConnectionHint: () => null,
|
||||
},
|
||||
});
|
||||
expect(await store.addHost({ connection: "tcp://example.com:6767?password=secret" })).toEqual({
|
||||
status: "password_required",
|
||||
reason: "incorrect_password",
|
||||
});
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
failure = "network";
|
||||
await expect(
|
||||
store.addHost({ connection: "tcp://example.com:6767?password=secret" }),
|
||||
).rejects.not.toThrow("secret");
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("rejects websocket paths and query options the app cannot represent", async () => {
|
||||
const store = createPairingStore();
|
||||
await expect(store.addHost({ connection: "wss://example.com/custom" })).rejects.toThrow(
|
||||
"unsupported",
|
||||
);
|
||||
await expect(
|
||||
store.addHost({ connection: "wss://example.com/ws?token=secret" }),
|
||||
).rejects.toThrow("unsupported");
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { watchdog } from "@/watchdog";
|
||||
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
|
||||
import type { OwnedSubscription } from "@getpaseo/client";
|
||||
import type { HostDirectory } from "@getpaseo/protocol/hosts";
|
||||
import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target";
|
||||
import { useSyncExternalStore, useMemo } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import equal from "fast-deep-equal/es6";
|
||||
@@ -1613,48 +1612,6 @@ interface AgentDirectoryRefreshInput {
|
||||
page?: FetchAgentsOptions["page"];
|
||||
}
|
||||
|
||||
function directHostTarget(target: HostTarget, input: string): HostConnection {
|
||||
let connection: HostConnection;
|
||||
if (target.kind === "ssh") {
|
||||
connection = createRemoteSshHostConnection(target.ssh);
|
||||
} else {
|
||||
if (/^wss?:\/\//.test(input.trim())) {
|
||||
const url = new URL(target.url);
|
||||
if (url.pathname !== "/ws" || url.search)
|
||||
throw new Error("WebSocket path or query is unsupported by the app");
|
||||
}
|
||||
switch (target.connection.type) {
|
||||
case "directTcp": {
|
||||
const endpoint = normalizeHostPort(target.connection.endpoint);
|
||||
connection = {
|
||||
id: `direct:${endpoint}`,
|
||||
type: "directTcp",
|
||||
endpoint,
|
||||
useTls: target.connection.useTls,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "directSocket":
|
||||
connection = {
|
||||
id: `socket:${target.connection.path}`,
|
||||
type: "directSocket",
|
||||
path: target.connection.path,
|
||||
};
|
||||
break;
|
||||
case "directPipe":
|
||||
connection = {
|
||||
id: `pipe:${target.connection.path}`,
|
||||
type: "directPipe",
|
||||
path: target.connection.path,
|
||||
};
|
||||
break;
|
||||
case "relay":
|
||||
throw new Error("Unexpected relay target");
|
||||
}
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
export class HostRuntimeStore {
|
||||
private controllers = new Map<string, HostRuntimeController>();
|
||||
private hostObservations = new Map<
|
||||
@@ -2007,66 +1964,6 @@ export class HostRuntimeStore {
|
||||
);
|
||||
}
|
||||
|
||||
/** Add a saved host using the shared connection grammar. */
|
||||
async addHost(
|
||||
input: {
|
||||
connection: string;
|
||||
label?: string;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<
|
||||
| { status: "saved"; serverId: string }
|
||||
| { status: "password_required"; reason: "password_required" | "incorrect_password" }
|
||||
| { status: "cancelled" }
|
||||
> {
|
||||
const target = parseHostTarget(input.connection);
|
||||
await this.loadRegistry();
|
||||
try {
|
||||
if (signal?.aborted) throw new Error("Plugin has stopped");
|
||||
if (target.kind === "socket" && target.connection.type === "relay") {
|
||||
const approved = await this.confirmLink(target.connection.offer, signal);
|
||||
if (signal?.aborted) throw new Error("Plugin has stopped");
|
||||
if (!approved) return { status: "cancelled" };
|
||||
const result = await this.probeAndUpsertOffer(
|
||||
target.connection.offer,
|
||||
target.password,
|
||||
input.label,
|
||||
signal,
|
||||
);
|
||||
return { status: "saved", serverId: result.serverId };
|
||||
}
|
||||
const connection = directHostTarget(target, input.connection);
|
||||
const result = await this.probeAndUpsertConnection({
|
||||
connection,
|
||||
password: target.password,
|
||||
label: input.label,
|
||||
signal,
|
||||
});
|
||||
return { status: "saved", serverId: result.serverId };
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
// The failed probe can include a credential; do not attach it as a cause.
|
||||
// oxlint-disable-next-line preserve-caught-error
|
||||
throw new Error("Plugin has stopped");
|
||||
}
|
||||
const reason = getConnectionAuthFailureReason(error);
|
||||
if (reason) return { status: "password_required", reason };
|
||||
let message = error instanceof Error ? error.message : String(error);
|
||||
if (target.password) {
|
||||
for (const value of [
|
||||
target.password,
|
||||
encodeURIComponent(target.password),
|
||||
new URLSearchParams({ password: target.password }).toString().slice(9),
|
||||
]) {
|
||||
if (value) message = message.split(value).join("[redacted]");
|
||||
}
|
||||
}
|
||||
// The original error can contain a credential; do not expose it as a cause.
|
||||
// oxlint-disable-next-line preserve-caught-error
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async upsertDirectConnection(input: {
|
||||
serverId: string;
|
||||
endpoint: string;
|
||||
@@ -2096,7 +1993,6 @@ export class HostRuntimeStore {
|
||||
password?: string;
|
||||
label?: string;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
||||
if (input.connection.type === "relay") {
|
||||
throw new Error("Cannot probe a relay connection without a server id.");
|
||||
@@ -2117,10 +2013,6 @@ export class HostRuntimeStore {
|
||||
connection: input.connection,
|
||||
...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
|
||||
});
|
||||
if (input.signal?.aborted) {
|
||||
await client.close().catch(() => undefined);
|
||||
throw new Error("Plugin has stopped");
|
||||
}
|
||||
const profile = await this.upsertHostConnection({
|
||||
serverId,
|
||||
label: input.label ?? hostname ?? undefined,
|
||||
@@ -2170,9 +2062,9 @@ export class HostRuntimeStore {
|
||||
// A pairing link (a URL, a QR code, or a pasted link) asks the user to
|
||||
// confirm when it adds a new host or changes a saved host's key, relay, or
|
||||
// TLS setting. The answer needs the saved hosts, so it waits for them to load.
|
||||
private async confirmLink(offer: ConnectionOffer, signal?: AbortSignal): Promise<boolean> {
|
||||
private async confirmLink(offer: ConnectionOffer): Promise<boolean> {
|
||||
await this.loadRegistry();
|
||||
return this.hostConfirmations.confirmLink(offer, this.hosts, signal);
|
||||
return this.hostConfirmations.confirmLink(offer, this.hosts);
|
||||
}
|
||||
|
||||
getPendingHostConfirmation(): HostConfirmationRequest | null {
|
||||
@@ -2252,8 +2144,6 @@ export class HostRuntimeStore {
|
||||
private async probeAndUpsertOffer(
|
||||
offer: ConnectionOffer,
|
||||
password: string | undefined,
|
||||
label?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
||||
const connection = relayConnectionFromOffer(offer);
|
||||
const probeHost: HostProfile = {
|
||||
@@ -2269,12 +2159,7 @@ export class HostRuntimeStore {
|
||||
};
|
||||
const { client, hostname } = await this.deps.connectToDaemon({ host: probeHost, connection });
|
||||
await client.close().catch(() => undefined);
|
||||
if (signal?.aborted) throw new Error("Plugin has stopped");
|
||||
const profile = await this.upsertConnectionFromOffer(
|
||||
offer,
|
||||
label ?? hostname ?? undefined,
|
||||
password,
|
||||
);
|
||||
const profile = await this.upsertConnectionFromOffer(offer, hostname ?? undefined, password);
|
||||
return { profile, serverId: offer.serverId, hostname };
|
||||
}
|
||||
|
||||
@@ -2336,10 +2221,7 @@ export class HostRuntimeStore {
|
||||
}));
|
||||
}
|
||||
|
||||
async setHostBadgeDisplay(
|
||||
serverId: string,
|
||||
badgeDisplay: HostBadgeDisplay | null,
|
||||
): Promise<void> {
|
||||
async setHostBadgeDisplay(serverId: string, badgeDisplay: HostBadgeDisplay): Promise<void> {
|
||||
await this.updateHostAppearance(serverId, (host) => ({
|
||||
...host,
|
||||
appearance: { ...host.appearance, badgeDisplay },
|
||||
@@ -3152,7 +3034,7 @@ export interface HostMutations {
|
||||
beginLinkPairing: () => LinkPairing;
|
||||
renameHost: (serverId: string, label: string) => Promise<void>;
|
||||
setHostColor: (serverId: string, color: HostColor) => Promise<void>;
|
||||
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay | null) => Promise<void>;
|
||||
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay) => Promise<void>;
|
||||
removeHost: (serverId: string) => Promise<void>;
|
||||
removeConnection: (serverId: string, connectionId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -152,12 +152,6 @@ 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,14 +476,6 @@ 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;
|
||||
|
||||
@@ -92,18 +92,6 @@ function typeErrors(code: string): string[] {
|
||||
.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
|
||||
}
|
||||
describe("plugin SDK import boundaries", () => {
|
||||
it("exposes host handles and object-shaped additions from the client entry", () => {
|
||||
expect(
|
||||
typeErrors(`
|
||||
import { addHost, observeHosts, useHosts, type PluginHost } from "@getpaseo/plugin/client";
|
||||
const hosts: readonly PluginHost[] = useHosts();
|
||||
const stop = observeHosts((value) => { void value[0]?.openSettings(); });
|
||||
void addHost({ connection: "localhost:6767", label: "Local" });
|
||||
void hosts; stop();
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("publishes process capabilities without their Windows implementation helpers", () => {
|
||||
expect(Object.keys(server).sort()).toEqual([
|
||||
"defineAgentTool",
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import type { PluginHostSummary } from "./index.js";
|
||||
|
||||
export type PluginHostStatus = PluginHostSummary["status"];
|
||||
export type PluginHostColor =
|
||||
| "none"
|
||||
| "violet"
|
||||
| "sky"
|
||||
| "emerald"
|
||||
| "orange"
|
||||
| "pink"
|
||||
| "indigo"
|
||||
| "teal"
|
||||
| "red"
|
||||
| "amber"
|
||||
| "blue";
|
||||
export type PluginHostBadgeDisplay = "name" | "icon" | "hidden";
|
||||
export interface PluginHostAppearance {
|
||||
readonly color: PluginHostColor;
|
||||
/** Null uses the automatic local or remote default. */
|
||||
readonly badgeDisplay: PluginHostBadgeDisplay | null;
|
||||
}
|
||||
/** Saved connection settings without credentials. Routed connections have no URI. */
|
||||
export type PluginHostConnection =
|
||||
| {
|
||||
readonly id: string;
|
||||
readonly type: "directTcp";
|
||||
readonly uri: string;
|
||||
readonly endpoint: string;
|
||||
readonly useTls: boolean;
|
||||
}
|
||||
| {
|
||||
readonly id: string;
|
||||
readonly type: "directSocket";
|
||||
readonly uri: string;
|
||||
readonly path: string;
|
||||
}
|
||||
| {
|
||||
readonly id: string;
|
||||
readonly type: "directPipe";
|
||||
readonly uri: string;
|
||||
readonly path: string;
|
||||
}
|
||||
| {
|
||||
readonly id: string;
|
||||
readonly type: "remoteSsh";
|
||||
readonly uri: string;
|
||||
readonly host: string;
|
||||
readonly sshPort?: number;
|
||||
readonly daemonPort?: number;
|
||||
}
|
||||
| {
|
||||
readonly id: string;
|
||||
readonly type: "relay";
|
||||
readonly uri: string;
|
||||
readonly relayEndpoint: string;
|
||||
readonly useTls: boolean;
|
||||
readonly daemonPublicKeyB64: string;
|
||||
readonly minimumSecurityMode?: "protected";
|
||||
}
|
||||
| {
|
||||
readonly id: string;
|
||||
readonly type: "routed";
|
||||
readonly uri: null;
|
||||
readonly originServerId: string;
|
||||
};
|
||||
export interface PluginHostPatch {
|
||||
label?: string;
|
||||
appearance?: Partial<PluginHostAppearance>;
|
||||
}
|
||||
export interface PluginHost extends PluginHostSummary {
|
||||
readonly appearance: PluginHostAppearance;
|
||||
readonly connections: readonly PluginHostConnection[];
|
||||
readonly activeConnectionId: string | null;
|
||||
readonly error: string | null;
|
||||
readonly authFailureReason: "password_required" | "incorrect_password" | null;
|
||||
readonly lastOnlineAt: string | null;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
update(patch: PluginHostPatch): Promise<void>;
|
||||
/** Removes the host; throws for the desktop daemon host. */
|
||||
remove(): Promise<void>;
|
||||
/** Removing the last connection removes the host. */
|
||||
removeConnection(connectionId: string): Promise<void>;
|
||||
openSettings(): void;
|
||||
}
|
||||
export interface PluginHostAddInput {
|
||||
/** Shared host connection grammar; may contain a password for the probe. */
|
||||
connection: string;
|
||||
label?: string;
|
||||
}
|
||||
export type PluginHostAddResult =
|
||||
| { status: "saved"; host: PluginHost }
|
||||
| { status: "password_required"; reason: "password_required" | "incorrect_password" }
|
||||
| { status: "cancelled" };
|
||||
export declare function useHosts(): readonly PluginHost[];
|
||||
export declare function observeHosts(listener: (hosts: readonly PluginHost[]) => void): () => void;
|
||||
|
||||
export declare function addHost(input: PluginHostAddInput): Promise<PluginHostAddResult>;
|
||||
@@ -86,20 +86,7 @@ export interface PluginHostSummary {
|
||||
readonly status: "idle" | "connecting" | "online" | "offline" | "error";
|
||||
}
|
||||
/** Live configured hosts. Supplied by the app's client bundle loader. */
|
||||
export declare const useHosts: typeof import("./hosts.js").useHosts;
|
||||
export declare const observeHosts: typeof import("./hosts.js").observeHosts;
|
||||
export declare const addHost: typeof import("./hosts.js").addHost;
|
||||
export type {
|
||||
PluginHost,
|
||||
PluginHostStatus,
|
||||
PluginHostColor,
|
||||
PluginHostBadgeDisplay,
|
||||
PluginHostAppearance,
|
||||
PluginHostConnection,
|
||||
PluginHostPatch,
|
||||
PluginHostAddInput,
|
||||
PluginHostAddResult,
|
||||
} from "./hosts.js";
|
||||
export declare function useHosts(): readonly PluginHostSummary[];
|
||||
/** Borrow an online host's API under this installation's lifetime. */
|
||||
export declare function getPaseoClient(serverId: string): import("@getpaseo/client").PaseoApi;
|
||||
/** Open an absolute HTTP(S) URL using the client platform’s external opener. */
|
||||
|
||||
@@ -1,55 +1,5 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { parseHostTarget } from "./host-target.js";
|
||||
import { serializeSshTransportUri } from "./ssh-transport.js";
|
||||
import { serializeSocketHostTarget } from "./host-target.js";
|
||||
import {
|
||||
parseConnectionUri,
|
||||
serializeConnectionUri,
|
||||
serializeRelayConnectionUri,
|
||||
} from "./daemon-endpoints.js";
|
||||
|
||||
test("SSH and local socket targets round trip through their serializers", () => {
|
||||
const ssh = parseHostTarget("ssh://deploy@build-box:2222?daemonPort=7777");
|
||||
if (ssh.kind !== "ssh") throw new Error("expected SSH");
|
||||
expect(parseHostTarget(serializeSshTransportUri(ssh.ssh))).toEqual(ssh);
|
||||
for (const [type, path] of [
|
||||
["unix", "/tmp/paseo.sock"],
|
||||
["pipe", "\\\\.\\pipe\\paseo"],
|
||||
] as const) {
|
||||
const target = parseHostTarget(`${type}://${path}`);
|
||||
expect(parseHostTarget(serializeSocketHostTarget(type, path))).toEqual(target);
|
||||
}
|
||||
});
|
||||
|
||||
test("TCP and relay targets round trip through credential-free serializers", () => {
|
||||
const tcp = parseConnectionUri("tcp://example.com:6767?ssl=true&password=secret");
|
||||
const tcpUri = serializeConnectionUri(tcp);
|
||||
expect(tcpUri).not.toContain("secret");
|
||||
expect(parseHostTarget(tcpUri)).toMatchObject({
|
||||
connection: { type: "directTcp", endpoint: "example.com:6767", useTls: true },
|
||||
});
|
||||
const relay = parseHostTarget(
|
||||
"relay://relay.example:443/server?key=public&ssl=true&password=secret",
|
||||
);
|
||||
if (relay.kind !== "socket" || relay.connection.type !== "relay")
|
||||
throw new Error("expected relay");
|
||||
const relayUri = serializeRelayConnectionUri({ offer: relay.connection.offer });
|
||||
expect(relayUri).not.toContain("secret");
|
||||
expect(parseHostTarget(relayUri)).toMatchObject({ connection: relay.connection });
|
||||
});
|
||||
|
||||
test.each([
|
||||
["example.com:6767", { type: "directTcp", endpoint: "example.com:6767", useTls: false }],
|
||||
[
|
||||
"tcp://example.com:6767?ssl=true",
|
||||
{ type: "directTcp", endpoint: "example.com:6767", useTls: true },
|
||||
],
|
||||
["wss://example.com/ws", { type: "directTcp", endpoint: "example.com:443", useTls: true }],
|
||||
["unix:///tmp/paseo.sock", { type: "directSocket", path: "/tmp/paseo.sock" }],
|
||||
["pipe://mypipe", { type: "directPipe", path: "mypipe" }],
|
||||
])("%s exposes its structured connection", (input, connection) => {
|
||||
expect(parseHostTarget(input)).toMatchObject({ kind: "socket", connection });
|
||||
});
|
||||
|
||||
test.each([
|
||||
["127.0.0.1:6767", "tcp://127.0.0.1:6767"],
|
||||
|
||||
@@ -14,23 +14,12 @@ export type HostTarget =
|
||||
| {
|
||||
kind: "socket";
|
||||
url: string;
|
||||
connection:
|
||||
| { type: "directTcp"; endpoint: string; useTls: boolean }
|
||||
| { type: "directSocket"; path: string }
|
||||
| { type: "directPipe"; path: string }
|
||||
| { type: "relay"; offer: ConnectionOffer };
|
||||
socketPath?: string;
|
||||
password?: string;
|
||||
offer?: ConnectionOffer;
|
||||
description: string;
|
||||
};
|
||||
|
||||
/** Serialize a local socket target in the shared host grammar. */
|
||||
export function serializeSocketHostTarget(type: "unix" | "pipe", path: string): string {
|
||||
if (!path || path === "~") throw new Error("Invalid socket path");
|
||||
return `${type}://${path}`;
|
||||
}
|
||||
|
||||
/** The shared saved-host and CLI grammar. Errors never echo credential-bearing input. */
|
||||
export function parseHostTarget(input: string): HostTarget {
|
||||
try {
|
||||
@@ -68,9 +57,6 @@ function parse(input: string): HostTarget {
|
||||
const pipe = input.startsWith("pipe://") || input.startsWith("\\\\.\\pipe\\");
|
||||
return {
|
||||
kind: "socket",
|
||||
connection: pipe
|
||||
? { type: "directPipe", path: socketPath }
|
||||
: { type: "directSocket", path: socketPath },
|
||||
socketPath,
|
||||
url: pipe ? "ws://localhost/ws" : `ws+unix://${socketPath}:/ws`,
|
||||
description: `${pipe ? "pipe" : "unix"}://${socketPath}`,
|
||||
@@ -81,7 +67,6 @@ function parse(input: string): HostTarget {
|
||||
const endpoint = validatedEndpoint(/^\d+$/.test(input) ? `127.0.0.1:${input}` : input);
|
||||
return {
|
||||
kind: "socket",
|
||||
connection: { type: "directTcp", endpoint, useTls: false },
|
||||
url: buildDaemonWebSocketUrl(endpoint, { useTls: false }),
|
||||
description: `tcp://${endpoint}`,
|
||||
};
|
||||
@@ -90,7 +75,6 @@ function relayTarget(offer: ConnectionOffer, password?: string): HostTarget {
|
||||
const endpoint = validatedEndpoint(offer.relay.endpoint);
|
||||
return {
|
||||
kind: "socket",
|
||||
connection: { type: "relay", offer },
|
||||
url: buildRelayWebSocketUrl({
|
||||
endpoint,
|
||||
serverId: offer.serverId,
|
||||
@@ -113,7 +97,6 @@ function tcpTarget(input: string): HostTarget {
|
||||
);
|
||||
return {
|
||||
kind: "socket",
|
||||
connection: { type: "directTcp", endpoint, useTls: parts.useTls },
|
||||
url: buildDaemonWebSocketUrl(endpoint, { useTls: parts.useTls }),
|
||||
password: parts.password,
|
||||
description: `tcp://${endpoint}`,
|
||||
@@ -126,15 +109,7 @@ function websocketTarget(input: string): HostTarget {
|
||||
// Unknown query parameters may themselves contain credentials; discovery shows only the origin.
|
||||
if (url.pathname === "/") url.pathname = "/ws";
|
||||
url.searchParams.delete("password");
|
||||
const useTls = url.protocol === "wss:";
|
||||
const endpoint = `${url.hostname}:${url.port || (useTls ? "443" : "80")}`;
|
||||
return {
|
||||
kind: "socket",
|
||||
connection: { type: "directTcp", endpoint, useTls },
|
||||
url: url.toString(),
|
||||
password,
|
||||
description: url.origin,
|
||||
};
|
||||
return { kind: "socket", url: url.toString(), password, description: url.origin };
|
||||
}
|
||||
|
||||
function sshTarget(input: string): HostTarget {
|
||||
|
||||
@@ -60,19 +60,6 @@ export function parseSshTransportUri(value: string): SshTransportTarget {
|
||||
};
|
||||
}
|
||||
|
||||
/** Serialize an SSH target without daemon credentials. */
|
||||
export function serializeSshTransportUri(target: SshTransportTarget): string {
|
||||
const host = validateSshHost(target.host);
|
||||
const separator = host.lastIndexOf("@");
|
||||
const username = separator < 0 ? "" : `${encodeURIComponent(host.slice(0, separator))}@`;
|
||||
const hostname = host.slice(separator + 1);
|
||||
const authority = hostname.includes(":") ? `[${hostname}]` : hostname;
|
||||
const port = target.sshPort === undefined ? "" : `:${validatePort(target.sshPort, "SSH port")}`;
|
||||
const daemonPort = validatePort(target.daemonPort, "Daemon port");
|
||||
const query = daemonPort === DEFAULT_SSH_DAEMON_PORT ? "" : `?daemonPort=${daemonPort}`;
|
||||
return `ssh://${username}${authority}${port}${query}`;
|
||||
}
|
||||
|
||||
export function buildSshTunnelArgs(target: SshTransportTarget): string[] {
|
||||
const host = validateSshHost(target.host);
|
||||
const daemonPort = validatePort(target.daemonPort, "Daemon port");
|
||||
|
||||
@@ -1183,7 +1183,6 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou
|
||||
const session = await createSession();
|
||||
const internal: {
|
||||
turnState: "idle" | "foreground" | "autonomous";
|
||||
nextTurnOrdinal: number;
|
||||
routeSdkMessageFromPump: (
|
||||
message: Record<string, unknown>,
|
||||
activeQuery: QueryMock,
|
||||
@@ -1206,8 +1205,7 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou
|
||||
);
|
||||
|
||||
const firstRunId = internal.autonomousTurn?.id ?? null;
|
||||
expect(firstRunId).toBe("autonomous-turn-1");
|
||||
expect(internal.nextTurnOrdinal).toBe(2);
|
||||
expect(firstRunId).toMatch(/^autonomous-turn-[0-9a-f]+-1$/);
|
||||
|
||||
await internal.routeSdkMessageFromPump(
|
||||
{
|
||||
@@ -1220,7 +1218,6 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou
|
||||
queryMock,
|
||||
);
|
||||
expect(internal.autonomousTurn?.id).toBe(firstRunId);
|
||||
expect(internal.nextTurnOrdinal).toBe(2);
|
||||
|
||||
await internal.routeSdkMessageFromPump(
|
||||
{
|
||||
@@ -1360,7 +1357,7 @@ test("a main-session frame opens no turn until Claude starts one", async () => {
|
||||
// session start a turn of its own, with no prompt from the user.
|
||||
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
|
||||
await internal.routeSdkMessageFromPump(unboundTextDelta("picking this up"), queryMock);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
|
||||
expect(internal.autonomousTurn?.id).toMatch(/^autonomous-turn-[0-9a-f]+-1$/);
|
||||
|
||||
await internal.routeSdkMessageFromPump(successResult(), queryMock);
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
@@ -1410,7 +1407,7 @@ test("Claude waking up after a helper finishes gets an autonomous turn that its
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
|
||||
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
|
||||
expect(internal.autonomousTurn?.id).toMatch(/^autonomous-turn-[0-9a-f]+-1$/);
|
||||
expect(internal.turnState).toBe("autonomous");
|
||||
|
||||
await internal.routeSdkMessageFromPump(unboundTextDelta("the helper is done"), queryMock);
|
||||
@@ -1446,7 +1443,7 @@ test("a steer folded into a turn keeps one turn, and a turn Claude starts after
|
||||
|
||||
// A message that arrived while Claude wrote its final answer runs as a turn of its own.
|
||||
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-2");
|
||||
expect(internal.autonomousTurn?.id).toMatch(/^autonomous-turn-[0-9a-f]+-2$/);
|
||||
await internal.routeSdkMessageFromPump(successResult(), queryMock);
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
expect(internal.turnState).toBe("idle");
|
||||
@@ -1483,7 +1480,7 @@ test("an idle session state closes an autonomous turn that no result closed", as
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
|
||||
expect(internal.autonomousTurn?.id).toMatch(/^autonomous-turn-[0-9a-f]+-1$/);
|
||||
|
||||
// Safety net: Claude says nothing at all is running any more.
|
||||
await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock);
|
||||
@@ -1685,7 +1682,7 @@ test("a new turn clears an interrupt flag no result will consume", async () => {
|
||||
|
||||
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
|
||||
expect(internal.pendingInterruptAbort).toBe(false);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
|
||||
expect(internal.autonomousTurn?.id).toMatch(/^autonomous-turn-[0-9a-f]+-1$/);
|
||||
|
||||
// This turn's own failure is reported, not swallowed as the interrupted turn's leftover.
|
||||
await internal.routeSdkMessageFromPump(
|
||||
|
||||
@@ -887,6 +887,30 @@ describe("ClaudeAgentSession features", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("issues turn ids that stay unique after the session is reopened", async () => {
|
||||
// Idle runtime collection closes the provider session; the next prompt reopens it from
|
||||
// persistence while the daemon keeps the earlier turns' rows and their turn ids.
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-turn-ids-"));
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", path.join(tempRoot, "claude-config"));
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory: createQueryMock().queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const handle = { provider: "claude" as const, sessionId: "reopened-session" };
|
||||
const first = await client.resumeSession(handle, { cwd: process.cwd() });
|
||||
const { turnId: firstTurnId } = await first.startTurn("first prompt");
|
||||
await first.close();
|
||||
|
||||
const second = await client.resumeSession(handle, { cwd: process.cwd() });
|
||||
const { turnId: secondTurnId } = await second.startTurn("second prompt");
|
||||
await second.close();
|
||||
vi.unstubAllEnvs();
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
|
||||
expect(secondTurnId).not.toBe(firstTurnId);
|
||||
});
|
||||
|
||||
test("keeps an extraArgs settings file when Paseo sets Claude flag settings", async () => {
|
||||
const settingsDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-settings-"));
|
||||
const settingsPath = path.join(settingsDir, "settings.json");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
|
||||
import { createTurnIdSequence } from "../../turn-ids.js";
|
||||
import { validateProviderOptions } from "../../provider-options.js";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -2371,7 +2372,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private historySessionId: string | null = null;
|
||||
private replayingHistory = false;
|
||||
private turnState: TurnState = "idle";
|
||||
private nextTurnOrdinal = 1;
|
||||
private readonly nextTurnId = createTurnIdSequence(1);
|
||||
private cancelCurrentTurn: (() => void) | null = null;
|
||||
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
||||
private lastOptionsModel: string | null = null;
|
||||
@@ -3940,7 +3941,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private createTurnId(owner: "foreground" | "autonomous"): string {
|
||||
return `${owner}-turn-${this.nextTurnOrdinal++}`;
|
||||
return this.nextTurnId(owner);
|
||||
}
|
||||
|
||||
private isTerminalTurnEvent(event: AgentStreamEvent): boolean {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { type Dirent, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
@@ -100,6 +100,12 @@ import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
|
||||
import { AgentManager } from "../agent-manager.js";
|
||||
import { buildProviderRegistry } from "../provider-registry.js";
|
||||
import { sessionTurnIds } from "./test-utils/turn-ids.js";
|
||||
|
||||
let codexTurnId = sessionTurnIds("codex");
|
||||
beforeEach(() => {
|
||||
codexTurnId = sessionTurnIds("codex");
|
||||
});
|
||||
|
||||
interface CollaborationModeRecord {
|
||||
name: string;
|
||||
@@ -1281,6 +1287,29 @@ describe("Codex app-server provider", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("issues turn ids that stay unique after the thread is reopened", async () => {
|
||||
// Idle runtime collection closes the provider session; the next prompt reopens it from
|
||||
// persistence while the daemon keeps the earlier turns' rows and their turn ids.
|
||||
const beforeReopen = createFakeCodexAppServer();
|
||||
const firstSession =
|
||||
await createProviderWithFakeAppServer(beforeReopen).createSession(createConfig());
|
||||
const { turnId: firstTurnId } = await firstSession.startTurn("first turn");
|
||||
const saved = waitForNextEvent(firstSession, "thread_started");
|
||||
beforeReopen.startsTurn({ threadId: "thread-1" });
|
||||
await saved;
|
||||
const handle = firstSession.describePersistence()!;
|
||||
await firstSession.close();
|
||||
|
||||
const afterReopen = createFakeCodexAppServer();
|
||||
const resumed = await createProviderWithFakeAppServer(afterReopen).resumeSession(handle);
|
||||
try {
|
||||
const { turnId: secondTurnId } = await resumed.startTurn("turn after reopen");
|
||||
expect(secondTurnId).not.toBe(firstTurnId);
|
||||
} finally {
|
||||
await resumed.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the default effort when a session without one is resumed after a restart", async () => {
|
||||
const beforeRestart = createFakeCodexAppServer();
|
||||
const firstSession = await createProviderWithFakeAppServer(beforeRestart).createSession(
|
||||
@@ -4611,7 +4640,7 @@ describe("Codex app-server provider", () => {
|
||||
await expect(child).resolves.toMatchObject({
|
||||
type: "provider_subagent",
|
||||
provider: "codex",
|
||||
turnId: "codex-turn-0",
|
||||
turnId: codexTurnId(0),
|
||||
event: {
|
||||
type: "upsert",
|
||||
id: "legacy-only-child-thread",
|
||||
@@ -4621,7 +4650,7 @@ describe("Codex app-server provider", () => {
|
||||
await expect(spawn).resolves.toMatchObject({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
turnId: "codex-turn-0",
|
||||
turnId: codexTurnId(0),
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "spawn-legacy-only-child",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { validateProviderOptions } from "../provider-options.js";
|
||||
import { createTurnIdSequence } from "../turn-ids.js";
|
||||
import {
|
||||
getAgentStreamEventTurnId,
|
||||
type AgentPermissionAction,
|
||||
@@ -3462,7 +3463,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private threadRollbackAvailable = true;
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private startupEvents: AgentStreamEvent[] | null = null;
|
||||
private nextTurnOrdinal = 0;
|
||||
private readonly nextTurnId = createTurnIdSequence(0);
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
private activeClientMessageId: string | null = null;
|
||||
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
||||
@@ -5627,7 +5628,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private createTurnId(): string {
|
||||
return `codex-turn-${this.nextTurnOrdinal++}`;
|
||||
return this.nextTurnId("codex");
|
||||
}
|
||||
|
||||
private pendingNotifications: Promise<void> = Promise.resolve();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
AssistantMessageTimelineItem,
|
||||
AgentTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
import { sessionTurnIds } from "./test-utils/turn-ids.js";
|
||||
|
||||
// Deliberately an independent literal rather than the production constant these tests
|
||||
// guard: deriving the boundary from OPENCODE_SERVER_STARTUP_TIMEOUT_MS would keep the
|
||||
@@ -32,6 +33,11 @@ import type {
|
||||
const EXPECTED_STARTUP_BUDGET_MS = 30_000;
|
||||
const EXPECTED_EVENT_READINESS_BUDGET_MS = 45_000;
|
||||
|
||||
let openCodeTurnId = sessionTurnIds("opencode");
|
||||
beforeEach(() => {
|
||||
openCodeTurnId = sessionTurnIds("opencode");
|
||||
});
|
||||
|
||||
function tmpCwd(): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "opencode-agent-test-"));
|
||||
try {
|
||||
@@ -1638,10 +1644,10 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
item: { type: "user_message", clientMessageId: "client-message-1" },
|
||||
});
|
||||
expect(turn.events.map((event) => ("turnId" in event ? event.turnId : undefined))).toEqual([
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
openCodeTurnId(0),
|
||||
openCodeTurnId(0),
|
||||
openCodeTurnId(0),
|
||||
openCodeTurnId(0),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1833,7 +1839,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
type: "error",
|
||||
message: "Provider retry (attempt 1): model does not exist",
|
||||
},
|
||||
turnId: "opencode-turn-0",
|
||||
turnId: openCodeTurnId(0),
|
||||
});
|
||||
expect(events.some((event) => event.type === "turn_failed")).toBe(false);
|
||||
await session.close();
|
||||
@@ -2373,6 +2379,35 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("issues turn ids that stay unique after the session is reopened", async () => {
|
||||
// Idle runtime collection closes the provider session; the next prompt reopens it from
|
||||
// persistence while the daemon keeps the earlier turns' rows and their turn ids.
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const cwd = tmpCwd();
|
||||
const handle = { provider: "opencode" as const, sessionId: "ses_reopened", metadata: { cwd } };
|
||||
const turnIds: string[] = [];
|
||||
try {
|
||||
for (let instance = 0; instance < 2; instance += 1) {
|
||||
const openCode = new TestOpenCodeClient();
|
||||
openCode.sessionPromptAsyncEvents = [];
|
||||
runtime.enqueueClient(openCode);
|
||||
const session = await client.resumeSession(handle);
|
||||
try {
|
||||
turnIds.push((await session.startTurn("prompt")).turnId);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
expect(turnIds[1]).not.toBe(turnIds[0]);
|
||||
});
|
||||
|
||||
test("uses OpenCode native steer admission and reconciles the user echo", async () => {
|
||||
const { parent: session, openCode } = await createParentSession("ses_native_steer");
|
||||
openCode.sessionPromptAsyncEvents = [];
|
||||
@@ -2641,11 +2676,11 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(
|
||||
turnEventSignatures(events.filter((event) => event.type !== "provider_subagent")),
|
||||
).toEqual([
|
||||
["turn_started", "opencode-turn-0"],
|
||||
["turn_canceled", "opencode-turn-0"],
|
||||
["turn_started", "opencode-turn-1"],
|
||||
["timeline", "opencode-turn-1"],
|
||||
["turn_completed", "opencode-turn-1"],
|
||||
["turn_started", openCodeTurnId(0)],
|
||||
["turn_canceled", openCodeTurnId(0)],
|
||||
["turn_started", openCodeTurnId(1)],
|
||||
["timeline", openCodeTurnId(1)],
|
||||
["turn_completed", openCodeTurnId(1)],
|
||||
]);
|
||||
} finally {
|
||||
settleRetry.resolve();
|
||||
@@ -2706,7 +2741,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
turnId: "opencode-turn-1",
|
||||
turnId: openCodeTurnId(1),
|
||||
item: expect.objectContaining({ text: "Wake output streams live." }),
|
||||
}),
|
||||
),
|
||||
@@ -2726,11 +2761,11 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({ type: "turn_completed", turnId: "opencode-turn-1" }),
|
||||
expect.objectContaining({ type: "turn_completed", turnId: openCodeTurnId(1) }),
|
||||
),
|
||||
);
|
||||
await expect(session.startTurn("after the wake")).resolves.toEqual({
|
||||
turnId: "opencode-turn-2",
|
||||
turnId: openCodeTurnId(2),
|
||||
});
|
||||
} finally {
|
||||
settleAbort.resolve();
|
||||
@@ -2843,13 +2878,13 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(events).toContainEqual({
|
||||
type: "turn_started",
|
||||
provider: "opencode",
|
||||
turnId: "opencode-turn-1",
|
||||
turnId: openCodeTurnId(1),
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(events).toContainEqual({
|
||||
type: "turn_completed",
|
||||
provider: "opencode",
|
||||
turnId: "opencode-turn-1",
|
||||
turnId: openCodeTurnId(1),
|
||||
usage: expect.objectContaining({
|
||||
inputTokens: 7,
|
||||
outputTokens: 3,
|
||||
@@ -2858,7 +2893,8 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
}),
|
||||
);
|
||||
const autonomousCompletion = events.find(
|
||||
(event) => event.type === "turn_completed" && event.turnId === "opencode-turn-1",
|
||||
(event) =>
|
||||
event.type === "turn_completed" && openCodeTurnId(1).asymmetricMatch(event.turnId),
|
||||
);
|
||||
expect(autonomousCompletion?.type).toBe("turn_completed");
|
||||
if (autonomousCompletion?.type === "turn_completed") {
|
||||
@@ -2867,7 +2903,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
turnId: "opencode-turn-1",
|
||||
turnId: openCodeTurnId(1),
|
||||
item: expect.objectContaining({
|
||||
type: "assistant_message",
|
||||
text: "Autonomous wake completed.",
|
||||
@@ -2875,7 +2911,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
}),
|
||||
);
|
||||
await expect(session.startTurn("after autonomous wake")).resolves.toEqual({
|
||||
turnId: "opencode-turn-2",
|
||||
turnId: openCodeTurnId(2),
|
||||
});
|
||||
} finally {
|
||||
settleAbort.resolve();
|
||||
@@ -2954,7 +2990,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
type: "turn_canceled",
|
||||
provider: "opencode",
|
||||
reason: "interrupted",
|
||||
turnId: "opencode-turn-0",
|
||||
turnId: openCodeTurnId(0),
|
||||
});
|
||||
|
||||
openCode.emitEvent({
|
||||
@@ -2962,7 +2998,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
properties: { sessionID: "ses_retried_stop" },
|
||||
});
|
||||
await expect(session.startTurn("replacement")).resolves.toEqual({
|
||||
turnId: "opencode-turn-1",
|
||||
turnId: openCodeTurnId(1),
|
||||
});
|
||||
expect(openCode.calls.sessionPromptAsync).toHaveLength(2);
|
||||
} finally {
|
||||
@@ -3058,7 +3094,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(events).toContainEqual({
|
||||
type: "turn_started",
|
||||
provider: "opencode",
|
||||
turnId: "opencode-turn-1",
|
||||
turnId: openCodeTurnId(1),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -3767,7 +3803,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
];
|
||||
const replacement = session.startTurn("second");
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
await expect(replacement).resolves.toEqual({ turnId: "opencode-turn-1" });
|
||||
await expect(replacement).resolves.toEqual({ turnId: openCodeTurnId(1) });
|
||||
expect(countEvents(events, "turn_canceled")).toBe(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
@@ -4022,7 +4058,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(openCode.calls.sessionStatus).toHaveLength(1);
|
||||
|
||||
releaseIdle.resolve();
|
||||
await expect(replacement).resolves.toEqual({ turnId: "opencode-turn-1" });
|
||||
await expect(replacement).resolves.toEqual({ turnId: openCodeTurnId(1) });
|
||||
expect(openCode.calls.sessionPromptAsync).toHaveLength(2);
|
||||
expect(openCode.calls.sessionStatus.length).toBeGreaterThanOrEqual(2);
|
||||
} finally {
|
||||
@@ -4722,9 +4758,9 @@ describe("OpenCode provider subagent contract", () => {
|
||||
"turn_completed",
|
||||
]);
|
||||
expect(events.map((event) => ("turnId" in event ? event.turnId : undefined))).toEqual([
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
openCodeTurnId(0),
|
||||
openCodeTurnId(0),
|
||||
openCodeTurnId(0),
|
||||
]);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
@@ -4760,7 +4796,7 @@ describe("OpenCode provider subagent contract", () => {
|
||||
expect(events).toContainEqual({
|
||||
type: "turn_started",
|
||||
provider: "opencode",
|
||||
turnId: "opencode-turn-0",
|
||||
turnId: openCodeTurnId(0),
|
||||
});
|
||||
});
|
||||
for (const event of assistantTurnEvents({
|
||||
@@ -4774,9 +4810,9 @@ describe("OpenCode provider subagent contract", () => {
|
||||
|
||||
expect(childClient.calls.sessionStatus).toEqual([{ directory: "/workspace/repo" }]);
|
||||
expect(turnEventSignatures(events)).toEqual([
|
||||
["turn_started", "opencode-turn-0"],
|
||||
["timeline", "opencode-turn-0"],
|
||||
["turn_completed", "opencode-turn-0"],
|
||||
["turn_started", openCodeTurnId(0)],
|
||||
["timeline", openCodeTurnId(0)],
|
||||
["turn_completed", openCodeTurnId(0)],
|
||||
]);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
@@ -4881,9 +4917,9 @@ describe("OpenCode provider subagent contract", () => {
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(turnEventSignatures(events)).toEqual([
|
||||
["turn_started", "opencode-turn-0"],
|
||||
["timeline", "opencode-turn-0"],
|
||||
["turn_completed", "opencode-turn-0"],
|
||||
["turn_started", openCodeTurnId(0)],
|
||||
["timeline", openCodeTurnId(0)],
|
||||
["turn_completed", openCodeTurnId(0)],
|
||||
]);
|
||||
});
|
||||
expect(events).toContainEqual(
|
||||
@@ -4927,9 +4963,9 @@ describe("OpenCode provider subagent contract", () => {
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(turnEventSignatures(events)).toEqual([
|
||||
["turn_started", "opencode-turn-0"],
|
||||
["timeline", "opencode-turn-0"],
|
||||
["turn_completed", "opencode-turn-0"],
|
||||
["turn_started", openCodeTurnId(0)],
|
||||
["timeline", openCodeTurnId(0)],
|
||||
["turn_completed", openCodeTurnId(0)],
|
||||
]);
|
||||
});
|
||||
} finally {
|
||||
@@ -5003,7 +5039,7 @@ describe("OpenCode provider subagent contract", () => {
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(events).toEqual([
|
||||
{ type: "turn_started", provider: "opencode", turnId: "opencode-turn-0" },
|
||||
{ type: "turn_started", provider: "opencode", turnId: openCodeTurnId(0) },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -5118,12 +5154,12 @@ describe("OpenCode provider subagent contract", () => {
|
||||
|
||||
await streamDrained.promise;
|
||||
expect(events.filter((event) => event.type !== "provider_subagent")).toEqual([
|
||||
{ type: "turn_started", provider: "opencode", turnId: "opencode-turn-0" },
|
||||
{ type: "turn_started", provider: "opencode", turnId: openCodeTurnId(0) },
|
||||
{
|
||||
type: "turn_canceled",
|
||||
provider: "opencode",
|
||||
reason: "interrupted",
|
||||
turnId: "opencode-turn-0",
|
||||
turnId: openCodeTurnId(0),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
@@ -5174,7 +5210,7 @@ describe("OpenCode provider subagent contract", () => {
|
||||
expect.objectContaining({
|
||||
type: "permission_requested",
|
||||
request: expect.objectContaining({ id: "perm_child_external" }),
|
||||
turnId: "opencode-turn-0",
|
||||
turnId: openCodeTurnId(0),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { validateProviderOptions } from "../provider-options.js";
|
||||
import { createTurnIdSequence } from "../turn-ids.js";
|
||||
import {
|
||||
createOpencodeClient,
|
||||
type AssistantMessage as OpenCodeAssistantMessage,
|
||||
@@ -3405,7 +3406,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private partTypes = new Map<string, string>();
|
||||
private availableModesCache: AgentMode[] | null = null;
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private nextTurnOrdinal = 0;
|
||||
private readonly nextTurnId = createTurnIdSequence(0);
|
||||
private turnState: OpenCodeTurnState = { status: "idle" };
|
||||
/**
|
||||
* Settlement of every session-scoped abort issued so far. It outlives the stop
|
||||
@@ -4882,7 +4883,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
|
||||
private createTurnId(): string {
|
||||
this.gapRepairRevision += 1;
|
||||
return `opencode-turn-${this.nextTurnOrdinal++}`;
|
||||
return this.nextTurnId("opencode");
|
||||
}
|
||||
|
||||
private traceOpenCode(msg: OpenCodeTraceMessage, data: OpenCodeTraceData = {}): void {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/** A Vitest asymmetric matcher for one provider turn id. */
|
||||
export interface TurnIdMatcher {
|
||||
asymmetricMatch(actual: unknown): boolean;
|
||||
toString(): string;
|
||||
toAsymmetricMatcher(): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches the turn ids one provider session issues. Each session mixes a random nonce into its
|
||||
* ids, so the first id seen binds that nonce and every later match must be the exact id
|
||||
* `<owner>-turn-<nonce>-<ordinal>`. Two events of one turn therefore match only when their ids
|
||||
* are identical. Create one per test.
|
||||
*/
|
||||
export function sessionTurnIds(owner: string): (ordinal: number) => TurnIdMatcher {
|
||||
let nonce: string | null = null;
|
||||
const pattern = new RegExp(`^${owner}-turn-([0-9a-f]+)-(\\d+)$`);
|
||||
return (ordinal) => {
|
||||
const describe = () => `${owner}-turn-${nonce ?? "<nonce>"}-${ordinal}`;
|
||||
return {
|
||||
asymmetricMatch(actual) {
|
||||
const match = typeof actual === "string" ? pattern.exec(actual) : null;
|
||||
if (!match || Number(match[2]) !== ordinal) return false;
|
||||
nonce ??= match[1]!;
|
||||
return match[1] === nonce;
|
||||
},
|
||||
toString: describe,
|
||||
toAsymmetricMatcher: describe,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Turn ids stay unique for an agent's whole life. The daemon keeps every turn's rows while
|
||||
* idle runtime collection closes the provider session, and the next prompt reopens the agent
|
||||
* as a new session instance whose ordinals start over. Each instance therefore mixes its own
|
||||
* nonce into the ids it issues; the ordinal keeps the sequence readable in logs and tests.
|
||||
*/
|
||||
export function createTurnIdSequence(firstOrdinal: number): (owner: string) => string {
|
||||
const instance = randomBytes(6).toString("hex");
|
||||
let ordinal = firstOrdinal;
|
||||
return (owner) => `${owner}-turn-${instance}-${ordinal++}`;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
FetchCatalogOptions,
|
||||
} from "../agent/agent-sdk-types.js";
|
||||
import type { AgentPermissionRequest, AgentPermissionResponse } from "../agent/agent-sdk-types.js";
|
||||
import { createTurnIdSequence } from "../agent/turn-ids.js";
|
||||
import { isLikelyExternalToolName } from "@getpaseo/protocol/tool-name-normalization";
|
||||
|
||||
const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
||||
@@ -342,7 +343,7 @@ class FakeAgentSession implements AgentSession {
|
||||
private permissionGate: Deferred<AgentPermissionResponse> | null = null;
|
||||
private readonly historyPath: string;
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private nextTurnOrdinal = 0;
|
||||
private readonly nextTurnId = createTurnIdSequence(0);
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
|
||||
readonly steerActiveTurn: AgentSession["steerActiveTurn"];
|
||||
@@ -455,7 +456,7 @@ class FakeAgentSession implements AgentSession {
|
||||
throw new Error("A foreground turn is already active");
|
||||
}
|
||||
|
||||
const turnId = `fake-turn-${this.nextTurnOrdinal++}`;
|
||||
const turnId = this.nextTurnId("fake");
|
||||
this.activeForegroundTurnId = turnId;
|
||||
this.onStartTurn?.(prompt, this.config);
|
||||
|
||||
|
||||
@@ -1,32 +1,16 @@
|
||||
import {
|
||||
addHost,
|
||||
getPaseoClient,
|
||||
useHosts,
|
||||
usePaseo,
|
||||
type PluginSurfaceProps,
|
||||
type PluginHost,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Pressable, Text, TextInput, View } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
|
||||
export function Hosts({ theme }: PluginSurfaceProps) {
|
||||
const hosts = useHosts();
|
||||
const selected = usePaseo();
|
||||
const [result, setResult] = useState("");
|
||||
const [connection, setConnection] = useState("");
|
||||
const add = useCallback(async () => {
|
||||
try {
|
||||
const outcome = await addHost({ connection });
|
||||
if (outcome.status === "saved") setResult(`Added ${outcome.host.label}`);
|
||||
else if (outcome.status === "cancelled") setResult("Host addition cancelled");
|
||||
else setResult("Add ?password= and retry");
|
||||
} catch (error) {
|
||||
setResult(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}, [connection]);
|
||||
const addPressed = useCallback(() => {
|
||||
void add();
|
||||
}, [add]);
|
||||
const list = useCallback(
|
||||
async (serverId?: string) => {
|
||||
try {
|
||||
@@ -49,63 +33,27 @@ export function Hosts({ theme }: PluginSurfaceProps) {
|
||||
const listSelected = useCallback(() => {
|
||||
void list();
|
||||
}, [list]);
|
||||
const rows = hosts.map((host) => ({
|
||||
serverId: host.serverId,
|
||||
label: host.label,
|
||||
status: host.status,
|
||||
onPress() {
|
||||
void list(host.serverId);
|
||||
},
|
||||
}));
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<TextInput
|
||||
value={connection}
|
||||
onChangeText={setConnection}
|
||||
placeholder="host:port, ssh://user@host, or a pairing link"
|
||||
/>
|
||||
<Pressable accessibilityRole="button" onPress={addPressed}>
|
||||
<Text style={styles.text}>Add host</Text>
|
||||
</Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={listSelected}>
|
||||
<Text style={styles.text}>List selected host agents</Text>
|
||||
</Pressable>
|
||||
{hosts.map((host) => (
|
||||
<HostRow key={host.serverId} host={host} color={theme.colors.foreground} list={list} />
|
||||
{rows.map((host) => (
|
||||
<Pressable key={host.serverId} accessibilityRole="button" onPress={host.onPress}>
|
||||
<Text style={styles.text}>
|
||||
{host.label}: {host.status}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
<Text style={styles.text}>{result}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function HostRow({
|
||||
host,
|
||||
color,
|
||||
list,
|
||||
}: {
|
||||
host: PluginHost;
|
||||
color: string;
|
||||
list: (serverId: string) => Promise<void>;
|
||||
}) {
|
||||
const style = useMemo(() => ({ color }), [color]);
|
||||
const rowStyle = useMemo(() => ({ gap: 4 }), []);
|
||||
const listAgents = useCallback(() => void list(host.serverId), [host, list]);
|
||||
const update = useCallback(
|
||||
() => void host.update({ label: `${host.label} (build)`, appearance: { color: "sky" } }),
|
||||
[host],
|
||||
);
|
||||
const open = useCallback(() => host.openSettings(), [host]);
|
||||
const remove = useCallback(() => void host.remove(), [host]);
|
||||
return (
|
||||
<View style={rowStyle}>
|
||||
<Text style={style}>
|
||||
{host.label}: {host.status} ·{" "}
|
||||
{host.connections.map((connection) => connection.uri ?? "via another host").join(", ")}
|
||||
</Text>
|
||||
<Pressable accessibilityRole="button" onPress={listAgents}>
|
||||
<Text style={style}>List agents</Text>
|
||||
</Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={update}>
|
||||
<Text style={style}>Rename and color</Text>
|
||||
</Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={open}>
|
||||
<Text style={style}>Host settings</Text>
|
||||
</Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={remove}>
|
||||
<Text style={style}>Remove host</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client";
|
||||
import { observeHosts } from "@getpaseo/plugin/client";
|
||||
import { SidebarRow } from "@getpaseo/plugin/client/ui";
|
||||
import { Hosts } from "./client/hosts";
|
||||
|
||||
@@ -12,8 +11,5 @@ function HostsItem({ currentScreen, openScreen }: PluginSidebarItemProps) {
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addScreen({ id: "main", title: "Host agents", Component: Hosts });
|
||||
client.addSidebarHeaderItem({ id: "hosts", title: "Host agents", Component: HostsItem });
|
||||
return observeHosts((hosts) => {
|
||||
for (const host of hosts)
|
||||
if (host.authFailureReason) console.log(`${host.label} requires attention`);
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ "id": "host-agents", "requirements": { "paseo": ">=0.15.0" } }
|
||||
{ "id": "host-agents", "requirements": { "paseo": ">=0.8.0" } }
|
||||
|
||||
@@ -94,7 +94,7 @@ type PluginTurnOutcome =
|
||||
|
||||
| Field | Shape / meaning |
|
||||
| ------------ | -------------------------------------------------------------------------------------- |
|
||||
| `turnId` | Provider-reported `string` or `null`; can repeat after a session reopens |
|
||||
| `turnId` | Provider-reported `string` or `null`; unique for the agent's lifetime |
|
||||
| `timeline` | Deprecated, always `[]`; fetch history with the SDK. |
|
||||
| `request` | SDK `AgentPermissionRequest`; `kind` is `tool`, `plan`, `question`, `mode`, or `other` |
|
||||
| `resolution` | SDK `AgentPermissionResponse` |
|
||||
|
||||
@@ -92,7 +92,7 @@ type PluginTurnOutcome =
|
||||
|
||||
| 字段 | 结构 / 含义 |
|
||||
| ------------ | ---------------------------------------------------------------------------------------- |
|
||||
| `turnId` | Provider 报告的 `string` 或 `null`;会话重新打开后可能重复 |
|
||||
| `turnId` | Provider 报告的 `string` 或 `null`;在 agent 的整个生命周期内唯一 |
|
||||
| `timeline` | 已弃用,始终为 `[]`;通过 SDK 获取历史。 |
|
||||
| `request` | SDK 的 `AgentPermissionRequest`;`kind` 为 `tool`、`plan`、`question`、`mode` 或 `other` |
|
||||
| `resolution` | SDK 的 `AgentPermissionResponse` |
|
||||
|
||||
@@ -2078,7 +2078,7 @@ function PullRequestAction({ theme }: PluginSurfaceProps) {
|
||||
|
||||
The returned API covers projects, workspaces, agents, terminals, providers, and daemon config. See the [SDK API reference](/docs/sdk/reference) for its methods. Connection lifecycle methods are intentionally absent because Paseo owns the connection.
|
||||
|
||||
### Hosts
|
||||
### Discover hosts and target another host
|
||||
|
||||
Use `useHosts()` to display configured hosts and `getPaseoClient(serverId)` in an action callback
|
||||
to run SDK operations on one of them:
|
||||
@@ -2124,36 +2124,14 @@ export function HostAgents({ theme }: Pick<PluginSurfaceProps, "theme">): ReactE
|
||||
}
|
||||
```
|
||||
|
||||
`useHosts(): readonly PluginHost[]` includes offline hosts and updates when projected fields change.
|
||||
`observeHosts(listener)` immediately delivers the same list and returns an unsubscribe function.
|
||||
Set `requirements.paseo` in the manifest to the release that ships these host capabilities.
|
||||
`useHosts(): readonly PluginHostSummary[]` includes offline hosts and updates when hosts,
|
||||
labels, or statuses change.
|
||||
|
||||
| Host field | Meaning |
|
||||
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `serverId` | ID to pass to `getPaseoClient`. |
|
||||
| `label` | Display name. |
|
||||
| `status` | `idle`, `connecting`, `online`, `offline`, or `error`. SDK calls require `online`. |
|
||||
| `appearance` | `color` and `badgeDisplay`; `null` badge display uses the local or remote default. |
|
||||
| `connections` | Saved connections with an ID, type, credential-free `uri`, and type-specific settings. Routed connections have a null URI. |
|
||||
| `activeConnectionId` | ID of the active connection, or null. |
|
||||
| `error` | Current connection error, or null. |
|
||||
| `authFailureReason` | `password_required`, `incorrect_password`, or null. |
|
||||
| `lastOnlineAt`, `createdAt`, `updatedAt` | ISO timestamps; `lastOnlineAt` can be null. |
|
||||
|
||||
Call `host.update({ label, appearance })`, `host.removeConnection(id)`, `host.remove()`,
|
||||
or `host.openSettings()` to manage a host. Updating `appearance.badgeDisplay` to null restores
|
||||
the automatic default. Removing the last connection removes the host. The desktop daemon host
|
||||
cannot be removed by a plugin.
|
||||
|
||||
Use `addHost({ connection, label? })` with the [shared connection grammar](../cli.md#connected-hosts).
|
||||
A connection may include `?password=`. The password is used for the probe and saved locally; plugins
|
||||
cannot read it back. Relay targets and pairing links ask the user to confirm.
|
||||
|
||||
| Result | Meaning |
|
||||
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `{ status: "saved", host }` | The host or connection was saved. |
|
||||
| `{ status: "password_required", reason }` | Nothing was saved; supply a password and retry. The reason is `password_required` or `incorrect_password`. |
|
||||
| `{ status: "cancelled" }` | The user declined the confirmation. |
|
||||
| Summary field | Type or values | Meaning |
|
||||
| ------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
|
||||
| `serverId` | `string` | ID to pass to `getPaseoClient`. |
|
||||
| `label` | `string` | Host's display name. |
|
||||
| `status` | `"idle"`, `"connecting"`, `"online"`, `"offline"`, `"error"` | Current app connection status. SDK calls require `"online"`. |
|
||||
|
||||
`getPaseoClient(serverId: string): PaseoApi` borrows the host's authenticated app connection.
|
||||
Call it in client entry code or callbacks; it opens no socket and does not require the plugin
|
||||
@@ -2167,13 +2145,13 @@ on the target daemon. Acquire the API when performing an action to use the curre
|
||||
| Connection settings change or the app switches connections, including automatic failover | The old API is released. Call `getPaseoClient(serverId)` again and recreate subscriptions. |
|
||||
| Host is removed | Its API is released; the removed ID is unknown. |
|
||||
| `client.dispose()` | Releases that API and its observations. A later getter call returns a fresh API over the app connection. Disposing the old API again leaves the new API usable. |
|
||||
| Originating plugin unloads | Borrowed APIs and host observations are released. Later host handle calls throw `Plugin has stopped`. |
|
||||
| Originating plugin unloads | All its borrowed APIs and observations are released, including those targeting other hosts. Retained handles cannot outlive the installation. |
|
||||
| Surface host selection changes | `usePaseo()` follows the selected host. An explicitly acquired API keeps its original target. |
|
||||
|
||||
You can also release individual subscriptions through the normal SDK API.
|
||||
|
||||
Plugins see connection targets and daemon identities but cannot read saved passwords. Borrowed APIs
|
||||
provide no connection lifecycle controls. See the
|
||||
Plugins are trusted app code; cross-host access is intentional. Summaries contain no connection
|
||||
URLs or credentials, and borrowed APIs provide no connection lifecycle controls. See the
|
||||
[host agents example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/hosts).
|
||||
|
||||
## Add plugin-specific backend behavior
|
||||
|
||||
@@ -1779,7 +1779,7 @@ function PullRequestAction({ theme }: PluginSurfaceProps) {
|
||||
|
||||
返回的 API 涵盖项目、工作区、Agent、终端、Provider 和 daemon 配置。其方法请参阅 [SDK API 参考](/docs/sdk/reference)。这里刻意没有连接生命周期方法,因为连接由 Paseo 负责。
|
||||
|
||||
### 主机 {#hosts}
|
||||
### 发现主机并指定另一台主机 {#discover-hosts-and-target-another-host}
|
||||
|
||||
使用 `useHosts()` 显示已配置的主机,并在操作回调中使用 `getPaseoClient(serverId)` 对其中一台主机
|
||||
执行 SDK 操作:
|
||||
@@ -1825,33 +1825,13 @@ export function HostAgents({ theme }: Pick<PluginSurfaceProps, "theme">): ReactE
|
||||
}
|
||||
```
|
||||
|
||||
`useHosts(): readonly PluginHost[]` 包括离线主机,并在可见字段变化时更新。`observeHosts(listener)`
|
||||
立即提供相同的列表,并返回取消订阅函数。请将清单中的 `requirements.paseo` 设为提供这些主机能力的发布版本。
|
||||
`useHosts(): readonly PluginHostSummary[]` 包括离线的主机,并在主机、标签或状态变化时更新。
|
||||
|
||||
| 主机字段 | 含义 |
|
||||
| ---------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `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" }` | 用户拒绝了确认。 |
|
||||
| 摘要字段 | 类型或取值 | 含义 |
|
||||
| ---------- | ------------------------------------------------------------ | --------------------------------------------- |
|
||||
| `serverId` | `string` | 传给 `getPaseoClient` 的 ID。 |
|
||||
| `label` | `string` | 主机的显示名称。 |
|
||||
| `status` | `"idle"`、`"connecting"`、`"online"`、`"offline"`、`"error"` | 当前应用的连接状态。SDK 调用需要 `"online"`。 |
|
||||
|
||||
`getPaseoClient(serverId: string): PaseoApi` 借用该主机已认证的应用连接。请在客户端入口代码或回调中
|
||||
调用它;它不会打开 socket,也不要求目标 daemon 上安装了该插件。在执行操作时再获取 API,以便使用当前的
|
||||
@@ -1869,7 +1849,8 @@ export function HostAgents({ theme }: Pick<PluginSurfaceProps, "theme">): ReactE
|
||||
|
||||
你也可以通过普通的 SDK API 释放单个订阅。
|
||||
|
||||
插件可以查看连接目标和 daemon 身份,但不能读取已保存的密码。借用的 API 不提供连接生命周期控制。请参阅[主机 Agent 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/hosts)。
|
||||
插件是受信任的应用代码;跨主机访问是有意设计的。摘要中不包含连接 URL 或凭据,借用的 API 也不提供连接
|
||||
生命周期控制。请参阅[主机 Agent 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/hosts)。
|
||||
|
||||
## 添加插件特有的后端行为 {#add-plugin-specific-backend-behavior}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user