Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andRyan Swift 7ebc7fff8d fix(usage): show Copilot premium request quota
The usage source discarded quota snapshots, leaving the card with only a
plan and reset date. Publish finite premium usage through the existing
window interface and retain its remaining request count.

Co-authored-by: Ryan Swift <ryan@mlh.io>
2026-11-01 19:31:51 +00:00
42 changed files with 229 additions and 2428 deletions
+3 -3
View File
@@ -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,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 -12
View File
@@ -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);
},
+2 -15
View File
@@ -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();
-4
View File
@@ -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,
+7 -227
View File
@@ -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"));
+17 -319
View File
@@ -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 -5
View File
@@ -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,
+10 -24
View File
@@ -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();
});
}
+1 -294
View File
@@ -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([]);
});
});
+5 -123
View File
@@ -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;
-12
View File
@@ -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",
-98
View File
@@ -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>;
+1 -14
View File
@@ -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. */
-50
View File
@@ -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"],
+1 -26
View File
@@ -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 {
-13
View File
@@ -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");
+1 -1
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/create-agent-mode.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
@@ -78,7 +78,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
availableModes: OPENCODE_MODES,
}),
).toThrow(
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode in settings.modeId. Available modes for 'opencode': build, plan",
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode. Available modes for 'opencode': build, plan",
);
});
@@ -92,7 +92,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
availableModes: CODEX_MODES,
}),
).toThrow(
"cannot inherit mode '<none>' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access",
"cannot inherit mode '<none>' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access",
);
});
@@ -180,7 +180,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
targetUnattendedMode: "full-access",
}),
).toThrow(
"cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access",
"cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access",
);
});
@@ -195,7 +195,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
targetUnattendedMode: undefined,
}),
).toThrow(
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode in settings.modeId. Available modes for 'zai-custom': unknown",
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode. Available modes for 'zai-custom': unknown",
);
});
@@ -77,7 +77,7 @@ export function resolveAndValidateCreateAgentMode(
}
throw new Error(
`cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode in settings.modeId. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
`cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
);
}
@@ -1,190 +0,0 @@
import { expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
import { ClaudeAgentClient } from "./agent.js";
import type { ClaudeQueryFactory } from "./query.js";
const LIMIT = "You've hit your session limit · resets 5:20pm (Asia/Singapore)";
interface ScriptedTurn {
// An API error reply as Claude Code writes it: a synthetic assistant message with `error`.
errorReply?: string;
// Assistant text the model wrote before or after it.
before?: string;
after?: string;
isError: boolean;
usage?: {
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
totalCostUsd: number;
};
}
// A query dependency that speaks Claude Code's stream interface, one scripted turn per prompt.
function scriptedQuery(turns: ScriptedTurn[]): ClaudeQueryFactory {
return ({ prompt }) => {
let index = 0;
const assistant = (id: string, text: string, error?: string) => ({
type: "assistant",
session_id: "error-session",
parent_tool_use_id: null,
uuid: id,
message: {
id,
role: "assistant",
model: error ? "<synthetic>" : "claude-opus-5-5",
content: [{ type: "text", text }],
stop_reason: "stop_sequence",
usage: { input_tokens: 0, output_tokens: 0 },
},
...(error ? { error } : {}),
});
const messages = (async function* () {
if (typeof prompt === "string") throw new Error("Expected streaming input");
for await (const _message of prompt) {
const turn = turns[index];
index += 1;
yield {
type: "system",
subtype: "init",
session_id: "error-session",
permissionMode: "default",
model: "claude-opus-5-5",
};
if (turn.before) yield assistant(`before-${index}`, turn.before);
if (turn.errorReply) yield assistant(`error-${index}`, turn.errorReply, "rate_limit");
if (turn.after) yield assistant(`after-${index}`, turn.after);
yield {
type: "result",
subtype: "success",
session_id: "error-session",
duration_ms: 1,
duration_api_ms: 0,
is_error: turn.isError,
num_turns: 1,
result: turn.isError ? (turn.errorReply ?? "") : (turn.after ?? turn.errorReply ?? ""),
stop_reason: "stop_sequence",
total_cost_usd: turn.usage?.totalCostUsd ?? 0,
usage: {
input_tokens: turn.usage?.inputTokens ?? 0,
output_tokens: turn.usage?.outputTokens ?? 0,
cache_read_input_tokens: turn.usage?.cachedInputTokens ?? 0,
},
modelUsage: {},
permission_denials: [],
uuid: `result-${index}`,
};
}
})();
return Object.assign(messages, {
close: () => {
void messages.return();
},
interrupt: async () => undefined,
setModel: async () => undefined,
setPermissionMode: async () => undefined,
supportedModels: async () => [],
supportedCommands: async () => [],
}) as ReturnType<ClaudeQueryFactory>;
};
}
async function runTurns(turns: ScriptedTurn[]) {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: scriptedQuery(turns),
resolveBinary: async () => process.execPath,
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
try {
const results = [];
for (const [index] of turns.entries()) {
results.push(await collectSessionTurnEvents(session, `turn ${index + 1}`));
}
return results;
} finally {
await session.close();
}
}
function assistantTexts(events: Awaited<ReturnType<typeof runTurns>>[number]): string[] {
return events.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [],
);
}
function terminal(events: Awaited<ReturnType<typeof runTurns>>[number]) {
return events.find(
(event) =>
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled",
);
}
test("a turn Claude Code ends on an API error fails with the error, shown once", async () => {
const [limited, overloaded, next] = await runTurns([
{ errorReply: LIMIT, isError: true },
{
before: "Checking the deploy doc.",
errorReply: "API Error: 529 Overloaded",
isError: true,
},
{ after: "Done.", isError: false },
]);
expect(terminal(limited)).toMatchObject({
type: "turn_failed",
error: LIMIT,
});
expect(assistantTexts(limited)).toEqual([]);
expect(terminal(overloaded)).toMatchObject({
type: "turn_failed",
error: "API Error: 529 Overloaded",
});
expect(assistantTexts(overloaded)).toEqual(["Checking the deploy doc."]);
// The session keeps working after a failed turn.
expect(terminal(next)).toMatchObject({ type: "turn_completed" });
expect(assistantTexts(next)).toEqual(["Done."]);
});
test("a failed turn retains its final token counts and cost", async () => {
const usage = {
inputTokens: 120,
cachedInputTokens: 80,
outputTokens: 30,
totalCostUsd: 0.012,
};
const [failed] = await runTurns([
{ before: "Checking the deploy doc.", errorReply: LIMIT, isError: true, usage },
]);
expect(failed).toContainEqual(
expect.objectContaining({ type: "usage_updated", usage: expect.objectContaining(usage) }),
);
expect(terminal(failed)).toMatchObject({ type: "turn_failed", error: LIMIT });
expect(assistantTexts(failed)).toEqual(["Checking the deploy doc."]);
});
test("an error reply the turn moves past is still shown", async () => {
const [recovered, completed] = await runTurns([
{
errorReply: "API Error: 529 Overloaded",
after: "Recovered.",
isError: false,
},
{ errorReply: LIMIT, isError: false },
]);
expect(terminal(recovered)).toMatchObject({ type: "turn_completed" });
expect(assistantTexts(recovered)).toEqual(["API Error: 529 Overloaded", "Recovered."]);
expect(terminal(completed)).toMatchObject({ type: "turn_completed" });
expect(assistantTexts(completed)).toEqual([LIMIT]);
});
@@ -2389,12 +2389,6 @@ class ClaudeAgentSession implements AgentSession {
private mainTurnInFlight = false;
private foregroundHasVisibleActivity = false;
private activeTurnHasAssistantText = false;
/**
* The reply Claude Code writes when a turn stops on an API error ("You've hit your session
* limit", "API Error: 529 ..."), held back until the result says whether the turn failed: a
* failed turn shows the same text as its error, so showing both would repeat it.
*/
private withheldErrorReply: AgentTimelineItem[] = [];
private readonly contextUsage: ClaudeContextUsageState;
private userMessageIds: string[] = [];
private readonly emittedUserMessageIds = new Set<string>();
@@ -2537,7 +2531,6 @@ class ClaudeAgentSession implements AgentSession {
this.activeForegroundTurnId = turnId;
this.foregroundHasVisibleActivity = false;
this.activeTurnHasAssistantText = false;
this.withheldErrorReply = [];
this.contextUsage.beginTurn();
this.transitionTurnState("foreground", "foreground turn started");
this.clearRecentStderr();
@@ -4426,21 +4419,6 @@ class ClaudeAgentSession implements AgentSession {
}) satisfies AgentStreamEvent,
);
if (!readClaudeParentToolUseId(message)) {
if (message.type === "assistant" && message.error) {
this.withheldErrorReply.push(...assistantTimelineEvents.map((event) => event.item));
return messageEvents;
}
if (
message.type === "assistant" ||
message.type === "user" ||
message.type === "stream_event"
) {
const released: AgentStreamEvent[] = [];
this.releaseWithheldErrorReply(released);
return [...released, ...messageEvents, ...assistantTimelineEvents];
}
}
return [...messageEvents, ...assistantTimelineEvents];
}
@@ -4988,23 +4966,6 @@ class ClaudeAgentSession implements AgentSession {
events: AgentStreamEvent[],
): void {
const usage = this.convertUsage(message);
// Claude Code reports a turn that stopped on an API error (a usage limit, an overloaded or
// rejected request) as `success` with `is_error`, carrying the error text in `result`.
if (message.subtype === "success" && message.is_error) {
const withheld = this.withheldErrorReply;
this.withheldErrorReply = [];
const resultText = typeof message.result === "string" ? message.result.trim() : "";
const withheldText = withheld
.map((item) => (item.type === "assistant_message" ? item.text : ""))
.join("\n")
.trim();
if (usage) events.push({ type: "usage_updated", provider: "claude", usage });
events.push(...this.sidechainTracker.finishAll("failed"));
events.push(this.buildTurnFailedEvent(resultText || withheldText || "Claude run failed"));
return;
}
const releasedErrorReply = this.withheldErrorReply.length > 0;
this.releaseWithheldErrorReply(events);
if (message.subtype === "success") {
events.push(...this.sidechainTracker.finishAll("completed"));
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
@@ -5014,12 +4975,7 @@ class ClaudeAgentSession implements AgentSession {
// accounting from provider gateways does not duplicate streamed output.
const resultText = typeof message.result === "string" ? message.result.trim() : "";
const outputTokens = message.usage?.output_tokens;
if (
resultText.length > 0 &&
outputTokens === 0 &&
!this.activeTurnHasAssistantText &&
!releasedErrorReply
) {
if (resultText.length > 0 && outputTokens === 0 && !this.activeTurnHasAssistantText) {
events.push({
type: "timeline",
provider: "claude",
@@ -5041,14 +4997,6 @@ class ClaudeAgentSession implements AgentSession {
events.push(this.buildTurnFailedEvent(errorMessage));
}
private releaseWithheldErrorReply(events: AgentStreamEvent[]): void {
if (this.withheldErrorReply.length === 0) return;
for (const item of this.withheldErrorReply) {
events.push({ type: "timeline", item, provider: "claude" });
}
this.withheldErrorReply = [];
}
private createClaudeSessionChangedNotice(
oldSessionId: string,
newSessionId: string,
@@ -150,15 +150,6 @@ function isCodexAlreadyUnarchivedError(error: unknown, threadId: string): boolea
const TURN_START_TIMEOUT_MS = 90 * 1000;
const INTERRUPT_TIMEOUT_MS = 2_000;
// `turn/interrupt` ends a turn but leaves the shell commands it started running.
// Each terminate is bounded by the first constant. A turn start waits for the
// interrupted turn's commands to end, at most the interrupt's own bound plus one
// terminate's and a margin, so it gives up only when a request outlives its timeout.
const INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS = 2_000;
const INTERRUPTED_TURN_CLEANUP_WAIT_MS =
INTERRUPT_TIMEOUT_MS + INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS + 500;
// Turns whose commands are remembered; older turns are forgotten first.
const INTERRUPTED_TURN_TRACKED_TURNS = 16;
const CODEX_PROVIDER = "codex" as const;
// These native provider identities belong to Codex, not to an older custom
// Paseo provider. Never include them in a custom provider's legacy import scan.
@@ -3485,14 +3476,6 @@ export class CodexAppServerAgentSession implements AgentSession {
private pendingFileChangeOutputDeltas = new Map<string, string[]>();
private pendingAssistantMessageBoundary = false;
private terminalCommandByProcessId = new Map<string, string>();
// The root thread's still-running command process ids, keyed by the native turn that
// started them. Codex's `thread/backgroundTerminals/list` carries no turn, so
// `item/started` is the only record of which turn owns a process.
private terminalProcessIdsByTurnId = new Map<
string,
{ processIds: Set<string>; terminatedProcessIds: Set<string>; interruptAccepted: boolean }
>();
private interruptedTurnCleanup: Promise<void> | null = null;
private pendingUnlabeledTerminalInteractions = new Map<
string,
Array<{ callId: string; stdin: string | null }>
@@ -4448,9 +4431,6 @@ export class CodexAppServerAgentSession implements AgentSession {
hasDeveloperInstructions: turnStart.hasDeveloperInstructions,
hasCodexConfig: turnStart.hasCodexConfig,
});
// The interrupted turn's commands end before this turn begins, so none of them
// outlives it into the new turn.
await this.waitForInterruptedTurnCleanup();
if (pendingStart.cancelRequested) {
throw new Error("Codex turn start was interrupted before reaching Codex");
}
@@ -5056,27 +5036,15 @@ export class CodexAppServerAgentSession implements AgentSession {
if (!turnId || (foregroundTurnId && this.activeForegroundTurnId !== foregroundTurnId)) {
throw new Error("Cannot interrupt Codex before turn/started identifies the active turn");
}
const client = this.client;
const threadId = this.currentThreadId;
const interruptRequest = client.request(
"turn/interrupt",
{
threadId,
turnId,
},
INTERRUPT_TIMEOUT_MS,
);
// Once Codex accepts the interrupt, end the commands this turn started. Registered
// before the await, so a turn start racing this interrupt waits for it. An already-idle
// turn was not interrupted, and what it left running stays.
this.trackInterruptedTurnCleanup(
interruptRequest.then(
() => this.endInterruptedTurnProcesses(client, threadId, turnId),
() => undefined,
),
);
try {
await interruptRequest;
await this.client.request(
"turn/interrupt",
{
threadId: this.currentThreadId,
turnId,
},
INTERRUPT_TIMEOUT_MS,
);
} catch (error) {
if (!isCodexAlreadyIdleInterrupt(error)) {
throw error;
@@ -5117,137 +5085,6 @@ export class CodexAppServerAgentSession implements AgentSession {
return !this.client.hasPendingRequests();
}
// Remember which turn started a command, so an interrupt ends that turn's commands and
// no other turn's. Only the root thread's: a subagent's commands live on its own thread.
// A command that starts after its turn's interrupt was accepted is ended as soon as it
// is seen.
private recordTurnTerminalProcess(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
): void {
const processId = parsed.item.processId;
if (
parsed.source !== "item" ||
!parsed.turnId ||
!parsed.threadId ||
parsed.threadId !== this.currentThreadId ||
typeof processId !== "string" ||
!processId ||
normalizeCodexThreadItemType(
typeof parsed.item.type === "string" ? parsed.item.type : undefined,
) !== "commandExecution"
) {
return;
}
const record = this.terminalProcessIdsByTurnId.get(parsed.turnId);
if (parsed.kind === "item_completed") {
// Only a completed command has exited. A failed one can still be running:
// Codex reports an interrupted turn's live command as failed.
if (parsed.item.status === "completed") record?.processIds.delete(processId);
return;
}
const current = record ?? this.getTurnTerminalRecord(parsed.turnId);
current.processIds.add(processId);
if (current.interruptAccepted && this.client) {
this.trackInterruptedTurnCleanup(
this.endInterruptedTurnProcesses(this.client, parsed.threadId, parsed.turnId),
);
}
}
// An accepted interrupt needs a record even before any of its turn's commands is seen:
// an `item/started` can still be queued behind an earlier notification's conversion.
private getTurnTerminalRecord(turnId: string) {
const existing = this.terminalProcessIdsByTurnId.get(turnId);
if (existing) return existing;
const record = {
processIds: new Set<string>(),
terminatedProcessIds: new Set<string>(),
interruptAccepted: false,
};
this.terminalProcessIdsByTurnId.set(turnId, record);
for (const oldestTurnId of this.terminalProcessIdsByTurnId.keys()) {
if (this.terminalProcessIdsByTurnId.size <= INTERRUPTED_TURN_TRACKED_TURNS) break;
this.terminalProcessIdsByTurnId.delete(oldestTurnId);
}
return record;
}
// A later sweep joins the pending one, so a turn start waits for both.
private trackInterruptedTurnCleanup(cleanup: Promise<void>): void {
const tracked = Promise.all([this.interruptedTurnCleanup, cleanup])
.then(() => undefined)
.finally(() => {
if (this.interruptedTurnCleanup === tracked) this.interruptedTurnCleanup = null;
});
this.interruptedTurnCleanup = tracked;
}
// Terminate each process the interrupted turn started, by its own id, once each.
// `thread/backgroundTerminals/clean` is never used: it ends every terminal on the thread,
// an earlier turn's included. A process already gone answers `terminated: false`. Never
// rejects; failures are logged.
private async endInterruptedTurnProcesses(
client: CodexAppServerClient,
threadId: string | null,
turnId: string,
): Promise<void> {
if (!threadId) return;
const record = this.getTurnTerminalRecord(turnId);
record.interruptAccepted = true;
const processIds = [...record.processIds].filter(
(processId) => !record.terminatedProcessIds.has(processId),
);
for (const processId of processIds) record.terminatedProcessIds.add(processId);
const logContext = { agentId: this.agentId, provider: CODEX_PROVIDER, threadId, turnId };
await Promise.all(
processIds.map(async (processId) => {
try {
const response = await client.request(
"thread/backgroundTerminals/terminate",
{ threadId, processId },
INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS,
);
this.logger.info(
{ ...logContext, processId, terminated: toObjectRecord(response)?.terminated ?? null },
"provider.codex.interrupt.terminal_terminated",
);
} catch (error) {
this.logger.warn(
{ ...logContext, processId, err: error },
"provider.codex.interrupt.terminal_terminate_failed",
);
}
}),
);
}
// A new turn starts only after every pending sweep of interrupted turns' commands has
// ended, or after the bound, whichever is first.
private async waitForInterruptedTurnCleanup(): Promise<void> {
if (!this.interruptedTurnCleanup) return;
let timer: ReturnType<typeof setTimeout> | undefined;
const timedOut = await Promise.race([
(async () => {
while (this.interruptedTurnCleanup) await this.interruptedTurnCleanup;
return false;
})(),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(true), INTERRUPTED_TURN_CLEANUP_WAIT_MS);
}),
]);
clearTimeout(timer);
if (timedOut) {
this.logger.warn(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
waitMs: INTERRUPTED_TURN_CLEANUP_WAIT_MS,
},
"provider.codex.interrupt.terminal_cleanup_timed_out",
);
}
}
async close(): Promise<void> {
this.closed = true;
this.clearPendingPermissions();
@@ -6372,11 +6209,6 @@ export class CodexAppServerAgentSession implements AgentSession {
return;
}
this.completePendingRootCompactions();
// What a turn that was not interrupted leaves running, it left on purpose; no later
// interrupt may end it.
if (parsed.status !== "interrupted" && this.currentTurnId) {
this.terminalProcessIdsByTurnId.delete(this.currentTurnId);
}
if (parsed.status === "failed") {
this.emitEvent({
type: "turn_failed",
@@ -6781,7 +6613,6 @@ export class CodexAppServerAgentSession implements AgentSession {
private async handleItemCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
): Promise<void> {
this.recordTurnTerminalProcess(parsed);
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
// and canonical `item/*`. Render ordinary items only from the canonical
// channel, but accept a legacy-only child announcement so it can establish
@@ -6944,7 +6775,6 @@ export class CodexAppServerAgentSession implements AgentSession {
private async handleItemStartedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
): Promise<void> {
this.recordTurnTerminalProcess(parsed);
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
return;
}
@@ -1,374 +0,0 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import type { AgentSession } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
import {
createFakeCodexAppServer,
type FakeCodexAppServer,
waitForNextEvent,
waitForTimelineToolCall,
} from "./codex/test-utils/fake-app-server.js";
const THREAD = "thread-1";
const TERMINATE = "thread/backgroundTerminals/terminate";
type Handler = (params: unknown) => unknown;
function deferred<T = void>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function paramsOf(value: unknown): Record<string, unknown> {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
let sessions: AgentSession[] = [];
afterEach(async () => {
for (const session of sessions) await session.close().catch(() => undefined);
sessions = [];
});
/**
* A Codex app-server whose interrupt ends the turn the way Codex does: an `interrupted`
* turn completion, then the response. `terminate` answers per process.
*/
function createHarness(
options: {
terminate?: (processId: string) => unknown;
interruptResponse?: (completeTurn: () => void) => unknown;
} = {},
) {
const handlers: Record<string, Handler> = {
"turn/interrupt": () => {
const completeTurn = () =>
appServer.completeTurn({ threadId: THREAD, status: "interrupted" });
if (options.interruptResponse) return options.interruptResponse(completeTurn);
completeTurn();
return {};
},
[TERMINATE]: (params) =>
options.terminate?.(String(paramsOf(params).processId)) ?? { terminated: true },
};
const appServer: FakeCodexAppServer = createFakeCodexAppServer(handlers);
const session = new CodexAppServerAgentSession(
{
provider: "codex",
cwd: "/workspace/project",
modeId: "auto",
model: "gpt-5.4",
},
null,
createTestLogger(),
async () => appServer.child,
);
sessions.push(session);
function requests(method: string): Record<string, unknown>[] {
return appServer
.requests()
.filter((message) => message.method === method && "id" in message)
.map((message) => paramsOf(message.params));
}
function terminated(): string[] {
return requests(TERMINATE)
.map((params) => String(params.processId))
.sort();
}
// Starts a turn and lets Codex identify it as `nativeTurnId`.
async function startTurn(prompt: string, nativeTurnId: string): Promise<void> {
const before = requests("turn/start").length;
const started = session.startTurn(prompt);
await vi.waitFor(() => expect(requests("turn/start")).toHaveLength(before + 1), {
timeout: 5_000,
});
const turnStarted = waitForNextEvent(session, "turn_started");
appServer.startsTurn({ threadId: THREAD, turnId: nativeTurnId });
await started;
await turnStarted;
}
async function runsCommand(params: { turnId: string; processId: string }): Promise<void> {
const itemId = `exec-${params.processId}`;
const shown = waitForTimelineToolCall(session, itemId);
appServer.startsTerminalCommand({
threadId: THREAD,
turnId: params.turnId,
itemId,
processId: params.processId,
command: `sleep ${params.processId}`,
});
await shown;
}
async function completesTurn(): Promise<void> {
const completed = waitForNextEvent(session, "turn_completed");
appServer.completeTurn({ threadId: THREAD });
await completed;
}
// Interrupts the running turn and waits for the session to report it canceled, as a
// caller does before it starts the next turn: Codex notifications are handled in order
// but asynchronously, so the interrupt's answer can arrive before the turn has ended.
async function interrupts(): Promise<void> {
const canceled = waitForNextEvent(session, "turn_canceled");
await session.interrupt();
await canceled;
}
return {
appServer,
session,
requests,
terminated,
startTurn,
runsCommand,
completesTurn,
interrupts,
};
}
// Turn A leaves a server running on purpose and completes. Turn B starts two
// commands, and a subagent on its own thread starts a third, then B is interrupted.
async function interruptTurnB(harness: ReturnType<typeof createHarness>): Promise<void> {
await harness.startTurn("start a server", "native-A");
await harness.runsCommand({ turnId: "native-A", processId: "101" });
await harness.completesTurn();
await harness.startTurn("run two commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
// A subagent thread's command shows no row here; the next command's row shows it
// was read, because notifications are read in order.
harness.appServer.startsTerminalCommand({
threadId: "child-thread",
turnId: "native-B",
itemId: "exec-209",
processId: "209",
command: "sleep 209",
});
await harness.runsCommand({ turnId: "native-B", processId: "202" });
await harness.interrupts();
}
describe("an interrupted Codex turn's commands end before the next turn", () => {
test("terminates exactly the processes the interrupted turn started, by id, and never cleans", async () => {
const harness = createHarness();
await interruptTurnB(harness);
await harness.startTurn("correction", "native-C");
expect(harness.terminated()).toEqual(["201", "202"]);
expect(harness.requests(TERMINATE).every((params) => params.threadId === THREAD)).toBe(true);
// `clean` would end every terminal on the thread, an earlier turn's included.
expect(
harness.appServer
.requests()
.filter((message) => String(message.method).startsWith("thread/backgroundTerminals/"))
.map((message) => message.method),
).toEqual([TERMINATE, TERMINATE]);
harness.appServer.assertNoErrors();
});
test("leaves the next turn's commands running when Codex starts that turn before acknowledging the interrupt", async () => {
const acknowledge = deferred();
const harness = createHarness({
// Codex ends B, starts a turn of its own (a goal continuation), and only then
// answers the interrupt.
interruptResponse: (completeTurn) => {
completeTurn();
return acknowledge.promise.then(() => ({}));
},
});
await harness.startTurn("run two commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
await harness.runsCommand({ turnId: "native-B", processId: "202" });
const interrupting = harness.session.interrupt();
await vi.waitFor(() => expect(harness.requests("turn/interrupt")).toHaveLength(1));
const autonomousTurn = waitForNextEvent(harness.session, "turn_started");
harness.appServer.startsTurn({ threadId: THREAD, turnId: "native-C" });
await autonomousTurn;
await harness.runsCommand({ turnId: "native-C", processId: "301" });
acknowledge.resolve();
await interrupting;
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(harness.terminated()).toEqual(["201", "202"]);
});
test("sends the next turn/start only after the terminates are answered", async () => {
const answer = deferred();
const harness = createHarness({
terminate: () => answer.promise.then(() => ({ terminated: true })),
});
await interruptTurnB(harness);
const next = harness.startTurn("correction", "native-C");
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(harness.requests("turn/start")).toHaveLength(2);
answer.resolve();
await next;
expect(harness.requests("turn/start")).toHaveLength(3);
});
test.each(["initial", "late"] as const)(
"tracks both cleanup sweeps when the %s answer arrives first",
async (answeredFirst) => {
const first = deferred();
const second = deferred();
const harness = createHarness({
terminate: (processId) =>
(processId === "201" ? first : second).promise.then(() => ({ terminated: true })),
});
await harness.startTurn("run commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
await harness.interrupts();
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201"]));
const next = harness.startTurn("correction", "native-C");
await new Promise((resolve) => setTimeout(resolve, 100));
expect(harness.requests("turn/start")).toHaveLength(1);
// The next turn is already waiting when another process is announced.
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "late-202",
processId: "202",
command: "sleep 202",
});
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "duplicate-202",
processId: "202",
command: "sleep 202",
});
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
(answeredFirst === "initial" ? first : second).resolve();
await new Promise((resolve) => setTimeout(resolve, 100));
expect(harness.requests("turn/start")).toHaveLength(1);
(answeredFirst === "initial" ? second : first).resolve();
await next;
expect(harness.terminated()).toEqual(["201", "202"]);
// Accepted state survives the completed sweep and a newer foreground turn.
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "late-203",
processId: "203",
command: "sleep 203",
});
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202", "203"]));
harness.appServer.assertNoErrors();
},
);
test("keeps the first sweep pending when a late sweep answers before the next start", async () => {
const first = deferred();
const harness = createHarness({
terminate: (processId) =>
processId === "201"
? first.promise.then(() => ({ terminated: true }))
: { terminated: true },
});
await harness.startTurn("run commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
await harness.interrupts();
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "late-202",
processId: "202",
command: "sleep 202",
});
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
const next = harness.startTurn("correction", "native-C");
await new Promise((resolve) => setTimeout(resolve, 100));
expect(harness.requests("turn/start")).toHaveLength(1);
first.resolve();
await next;
harness.appServer.assertNoErrors();
});
test("forgets completed commands but retains failed commands at interrupt", async () => {
const harness = createHarness();
await harness.startTurn("run commands", "native-B");
for (const processId of ["201", "202"]) {
await harness.runsCommand({ turnId: "native-B", processId });
harness.appServer.child.stdout.write(
JSON.stringify({
method: "item/completed",
params: {
threadId: THREAD,
turnId: "native-B",
item: {
id: `exec-${processId}`,
type: "commandExecution",
processId,
command: `sleep ${processId}`,
status: processId === "201" ? "completed" : "failed",
exitCode: processId === "201" ? 0 : null,
},
},
}) + "\n",
);
}
// A following command's row is a barrier for both completion handlers.
await harness.runsCommand({ turnId: "native-B", processId: "203" });
await harness.interrupts();
await harness.startTurn("correction", "native-C");
expect(harness.terminated()).toEqual(["202", "203"]);
harness.appServer.assertNoErrors();
});
test("retains accepted interrupts for only the latest sixteen turns", async () => {
const harness = createHarness();
for (let index = 0; index < 17; index++) {
await harness.startTurn("run a command", `native-${index}`);
await harness.interrupts();
}
await harness.startTurn("correction", "native-current");
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-0",
itemId: "evicted-201",
processId: "201",
command: "sleep 201",
});
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-16",
itemId: "retained-202",
processId: "202",
command: "sleep 202",
});
await harness.runsCommand({ turnId: "native-current", processId: "301" });
expect(harness.terminated()).toEqual(["202"]);
harness.appServer.assertNoErrors();
});
test("a failing or unanswered terminate does not block or fail the next turn", async () => {
const harness = createHarness({
// 201 is refused while the next turn waits; 202 is never answered.
terminate: (processId) =>
processId === "201"
? new Promise((resolve) => setTimeout(resolve, 300)).then(() => ({
__jsonRpcError: { code: -32600, message: "invalid background terminal process id" },
}))
: new Promise(() => {}),
});
await interruptTurnB(harness);
const startedAt = Date.now();
await harness.startTurn("correction", "native-C");
expect(Date.now() - startedAt).toBeLessThan(5_000);
expect(harness.requests("turn/start")).toHaveLength(3);
expect(harness.terminated()).toEqual(["201", "202"]);
}, 15_000);
});
@@ -4,102 +4,6 @@ import { createTestLogger } from "../../../../../test-utils/test-logger.js";
import type { V2Api } from "./api.js";
import { OpenCodeV2AgentClient } from "./agent.js";
import { V2Harness } from "../test-utils/v2-harness.js";
import type { AgentPermissionResponse } from "../../../agent-sdk-types.js";
describe("OpenCode v2 denial reasons", () => {
test.each<{
response: AgentPermissionResponse;
decision: "reject" | "once" | "always";
message?: string;
}>([
{
response: { behavior: "deny", message: "Use the existing file instead." },
decision: "reject",
message: "Use the existing file instead.",
},
{ response: { behavior: "deny" }, decision: "reject" },
{ response: { behavior: "allow" }, decision: "once" },
{ response: { behavior: "allow", selectedActionId: "always" }, decision: "always" },
])("preserves a permission response: $response", async ({ response, decision, message }) => {
const harness = new V2Harness();
let pending = true;
harness.api.permission.list = async () =>
pending
? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }]
: [];
const replies: Parameters<V2Api["permission"]["reply"]>[0][] = [];
harness.api.permission.reply = async (input) => {
replies.push(input);
pending = false;
};
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
try {
await session.respondToPermission("approval", response);
expect(replies).toEqual([
{
sessionID: "session",
requestID: "approval",
decision,
...(message === undefined ? {} : { message }),
},
]);
expect(session.getPendingPermissions()).toEqual([]);
} finally {
await session.close();
}
});
test.each([true, false])(
"explains cards superseded by a steer when clearing is %s",
async (clearPendingPermissions) => {
const harness = new V2Harness();
harness.autoComplete = false;
let pending = true;
harness.api.permission.list = async () =>
pending
? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }]
: [];
const replies: Parameters<V2Api["permission"]["reply"]>[0][] = [];
harness.api.permission.reply = async (input) => {
replies.push(input);
pending = false;
};
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
try {
const { turnId } = await session.startTurn("Run pwd");
await expect.poll(() => harness.prompts).toEqual(["Run pwd"]);
await expect(
session.steerActiveTurn!("go ahead", { expectedTurnId: turnId, clearPendingPermissions }),
).resolves.toEqual({ status: "accepted" });
expect(harness.prompts).toEqual(["Run pwd", "go ahead"]);
expect(replies).toEqual(
clearPendingPermissions
? [
{
sessionID: "session",
requestID: "approval",
decision: "reject",
message:
"The user answered with a message instead of approving. Their message follows.",
},
]
: [],
);
expect(session.getPendingPermissions()).toHaveLength(clearPendingPermissions ? 0 : 1);
} finally {
await session.close();
}
},
);
});
describe("OpenCode v2 questions", () => {
test("maps selected labels to native values and parses numeric and boolean answers", async () => {
@@ -68,9 +68,6 @@ export class SessionPermissions {
sessionID: this.permissionOwners.get(requestId) ?? this.id,
requestID: requestId,
decision: permissionReply(response),
...(response.behavior === "deny" && response.message !== undefined
? { message: response.message }
: {}),
});
}
this.resolvePending(requestId, response);
@@ -100,11 +100,7 @@ export class OpenCodeV2Session implements AgentSession {
),
clearPermissions: async () => {
for (const request of this.permissions.list())
await this.permissions.respondToPermission(request.id, {
behavior: "deny",
message:
"The user answered with a message instead of approving. Their message follows.",
});
await this.permissions.respondToPermission(request.id, { behavior: "deny" });
},
});
this.usage = new SessionUsage({
@@ -1590,7 +1590,7 @@ function createToolCatalog(
{
title: "Create agent",
description:
"Create an agent. Agent-scoped creation defaults to your workspace and creates your subagent. Top-level creation without workspaceId creates a new local workspace. Requires provider/model (for example codex/gpt-5.4) and an initial prompt. For a different provider, set settings.modeId from inspect_provider; omission is accepted for targets with no modes and for unattended execution supported by the target. Do not guess; call list_providers and list_models first if uncertain.",
"Create an agent. Agent-scoped creation defaults to your workspace and creates your subagent. Top-level creation without workspaceId creates a new local workspace. Requires provider/model (for example codex/gpt-5.4) and an initial prompt. Do not guess; call list_providers and list_models first if uncertain.",
inputSchema: createAgentInputSchema,
outputSchema: {
agentId: z.string(),
@@ -1,67 +0,0 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import path from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { expect, test } from "vitest";
import { createDaemonTestContext } from "../test-utils/index.js";
test("an attended MCP caller can discover and supply a cross-provider child mode", async () => {
const artifacts = path.join(process.cwd(), ".artifacts");
await mkdir(artifacts, { recursive: true });
const root = await mkdtemp(path.join(artifacts, "create-agent-mode-"));
const ctx = await createDaemonTestContext({ paseoHomeRoot: root, mcpEnabled: true });
const mcp = new Client({ name: "mode-contract-test", version: "1" });
try {
const parent = await ctx.client.createAgent({
provider: "claude",
cwd: root,
modeId: "default",
title: "Attended parent",
});
await mcp.connect(
new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${ctx.daemon.port}/mcp/agents?callerAgentId=${parent.id}`),
),
);
const { tools } = await mcp.listTools();
const create = tools.find((tool) => tool.name === "create_agent");
expect.soft(create?.description).toContain("settings.modeId");
const args = {
provider: "codex/gpt-5.4-mini",
title: "Cross-provider child",
initialPrompt: "Reply with OK",
};
const refused = await mcp.callTool({ name: "create_agent", arguments: args });
expect(refused.isError).toBe(true);
expect.soft(refused.content).toEqual([
expect.objectContaining({
type: "text",
text: expect.stringContaining("Pass an explicit mode in settings.modeId"),
}),
]);
expect((await ctx.client.fetchAgents({ subscribe: {} })).entries).toHaveLength(1);
const inspected = await mcp.callTool({
name: "inspect_provider",
arguments: { provider: "codex" },
});
expect(inspected.isError).not.toBe(true);
expect(inspected.structuredContent).toMatchObject({
modes: expect.arrayContaining([expect.objectContaining({ id: "auto" })]),
});
const created = await mcp.callTool({
name: "create_agent",
arguments: { ...args, settings: { modeId: "auto" } },
});
expect(created.isError).not.toBe(true);
expect(created.structuredContent).toMatchObject({ type: "codex", currentModeId: "auto" });
const childId = (created.structuredContent as { agentId: string }).agentId;
expect((await ctx.client.fetchAgent(childId))?.agent.currentModeId).toBe("auto");
expect((await ctx.client.waitForFinish(childId, 5000)).status).toBe("idle");
} finally {
await mcp.close();
await ctx.cleanup();
await rm(root, { recursive: true, force: true });
}
}, 30_000);
+15 -67
View File
@@ -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 -5
View File
@@ -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
View File
@@ -1 +1 @@
{ "id": "host-agents", "requirements": { "paseo": ">=0.15.0" } }
{ "id": "host-agents", "requirements": { "paseo": ">=0.8.0" } }
@@ -171,3 +171,90 @@ it.each([401, 403])("reports an existing login rejected with HTTP %i", async (st
else process.env["COPILOT_TOKEN"] = previous;
}
});
it.each([
{ remaining: 1500, percentRemaining: 100, usedPct: 0 },
{ remaining: 1125, percentRemaining: 75, usedPct: 25 },
{ remaining: 0, percentRemaining: 0, usedPct: 100 },
])(
"reports premium quota with $remaining requests remaining",
async ({ remaining, percentRemaining, usedPct }) => {
const report = await fetchPremiumQuota({
entitlement: 1500,
remaining,
percent_remaining: percentRemaining,
unlimited: false,
});
expect(report).toMatchObject({
status: "available",
planLabel: "individual",
windows: [
{
id: "premium_interactions",
label: "Premium requests",
usedPct,
remainingPct: percentRemaining,
resetsAt: "2026-12-01T00:00:00.000Z",
},
],
details: expect.arrayContaining([
{
id: "premium_remaining",
label: "Premium requests remaining",
value: `${remaining} / 1500`,
},
]),
});
},
);
it.each([
undefined,
null,
{ unlimited: true, percent_remaining: 100, remaining: 1500, entitlement: 1500 },
])(
"does not invent a finite quota for absent or unlimited premium snapshots (%j)",
async (premium) => {
const report = await fetchPremiumQuota(premium);
expect(report).toMatchObject({
status: "available",
windows: [],
details: [{ id: "reset", label: "Quota reset", value: "2026-12-01" }],
});
},
);
it("reports a percentage without inventing missing counts and falls back to the legacy reset", async () => {
const report = await fetchPremiumQuota({ percent_remaining: 60, unlimited: false }, null);
expect(report).toMatchObject({
status: "available",
windows: [{ usedPct: 40, remainingPct: 60, resetsAt: "2026-12-01" }],
details: [{ id: "reset", label: "Quota reset", value: "2026-12-01" }],
});
});
async function fetchPremiumQuota(
premium: unknown,
utcReset: string | null = "2026-12-01T00:00:00.000Z",
) {
const { writeFile } = await import("node:fs/promises");
const directory = mkdtempSync(join(tmpdir(), "copilot-premium-"));
const locator = join(directory, "hosts.yml");
try {
await writeFile(locator, "github.com:\n oauth_token: fixture-copilot\n");
return await fetchUsage({ store: "file", locator }, async () =>
jsonResponse({
copilot_plan: "individual",
quota_reset_date: "2026-12-01",
quota_reset_date_utc: utcReset,
quota_snapshots: {
premium_interactions: premium,
chat: { unlimited: true, percent_remaining: 100 },
completions: { unlimited: true, percent_remaining: 100 },
},
}),
);
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
+38 -1
View File
@@ -5,6 +5,8 @@ import { join } from "node:path";
import { z } from "zod";
import {
unavailable,
windowFromUsedPct,
type UsageWindow,
type UsageAccount,
type UsageReport,
type UsageDetail,
@@ -18,6 +20,19 @@ const ApiOptionalStringSchema = z.preprocess(
const CopilotUsageResponseSchema = z.object({
copilot_plan: ApiOptionalStringSchema,
quota_reset_date: ApiOptionalStringSchema,
quota_reset_date_utc: ApiOptionalStringSchema,
quota_snapshots: z
.object({
premium_interactions: z
.object({
entitlement: z.number().nonnegative().nullish(),
remaining: z.number().nonnegative().nullish(),
percent_remaining: z.number().min(0).max(100).nullish(),
unlimited: z.boolean().optional(),
})
.nullish(),
})
.nullish(),
});
async function readToken(input: UsageInput): Promise<string | undefined> {
@@ -72,10 +87,32 @@ export async function fetchUsage(
? [{ id: "reset", label: "Quota reset", value: resp.quota_reset_date }]
: [];
const windows: UsageWindow[] = [];
const premium = resp.quota_snapshots?.premium_interactions;
if (premium && !premium.unlimited) {
if (premium.percent_remaining != null) {
windows.push(
windowFromUsedPct({
id: "premium_interactions",
label: "Premium requests",
utilizationPct: 100 - premium.percent_remaining,
resetsAt: resp.quota_reset_date_utc || resp.quota_reset_date,
}),
);
}
if (premium.remaining != null && premium.entitlement != null) {
details.push({
id: "premium_remaining",
label: "Premium requests remaining",
value: `${premium.remaining} / ${premium.entitlement}`,
});
}
}
return {
status: "available",
planLabel: resp.copilot_plan || undefined,
windows: [],
windows,
balances: [],
details,
};
+11 -33
View File
@@ -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
+9 -28
View File
@@ -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}