Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,15 @@ describe("DesktopPiRpcClientFactory", () => {
createPiRpcClient.mockReturnValue(client);
const factory = new DesktopPiRpcClientFactory(auth, authProxy);

await expect(factory.create({ cwd: "/workspace" })).resolves.toBe(client);
await expect(
factory.create({ cwd: "/workspace", projectTrusted: true }),
).resolves.toBe(client);
expect(authProxy.start).toHaveBeenCalledWith(
getLlmGatewayUrl(getCloudUrlFromRegion("eu")),
);
expect(createPiRpcClient).toHaveBeenCalledWith({
cwd: "/workspace",
projectTrusted: true,
providerOptions: {
region: "eu",
baseUrl: "http://127.0.0.1:1234",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,9 @@ export class DesktopPiRpcClientFactory implements PiRpcClientFactory {
private readonly authProxy: AuthProxyService,
) {}

async create(input: {
cwd: string;
model?: string;
sessionFile?: string;
}): Promise<PiRpcClient> {
async create(
input: Parameters<PiRpcClientFactory["create"]>[0],
): Promise<PiRpcClient> {
const credentials = await this.auth.getOAuthCredentials();
if (!credentials) {
throw new Error("Pi requires PostHog authentication");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,16 @@ describe("DesktopPiRuntimeFactory", () => {
} as unknown as PiRpcClientFactory;
const factory = new DesktopPiRuntimeFactory(clientFactory);

const runtime = await factory.create({ cwd: "/workspace" });
const runtime = await factory.create({
cwd: "/workspace",
projectTrusted: true,
});

expect(runtime).toBeInstanceOf(PiRuntime);
expect(runtime.client).toBe(client);
expect(clientFactory.create).toHaveBeenCalledWith({ cwd: "/workspace" });
expect(clientFactory.create).toHaveBeenCalledWith({
cwd: "/workspace",
projectTrusted: true,
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,9 @@ export class DesktopPiRuntimeFactory implements PiRuntimeFactory {
private readonly clientFactory: PiRpcClientFactory,
) {}

async create(input: {
cwd: string;
model?: string;
sessionFile?: string;
}): Promise<PiRuntime> {
async create(
input: Parameters<PiRuntimeFactory["create"]>[0],
): Promise<PiRuntime> {
const client = await this.clientFactory.create(input);
return new PiRuntime(client);
}
Expand Down
176 changes: 176 additions & 0 deletions products/desktop/apps/code/tests/e2e/tests/pi-extension.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { existsSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import type { PiRpcClient } from "@posthog/agent/pi/rpc-client";
import type { PiExtensionEvent } from "@posthog/agent/pi/types";
import { expect, test } from "../fixtures/electron";

const EXTENSION_SOURCE = `
export default function (pi) {
pi.registerCommand("extension-e2e", {
description: "Exercise the PostHog Desktop extension RPC UI",
handler: async (_args, ctx) => {
ctx.ui.setTitle("Extension E2E");
ctx.ui.setStatus("extension-e2e", "Extension ready");
ctx.ui.setWidget("extension-e2e", ["Extension widget ready"]);
const confirmed = await ctx.ui.confirm(
"Extension confirmation",
"Confirm the extension RPC round trip.",
);
ctx.ui.notify(
confirmed ? "Extension E2E passed" : "Extension E2E cancelled",
confirmed ? "info" : "warning",
);
},
});
}
`;

const HOME_ENVIRONMENT_KEYS = [
"APPDATA",
"HOME",
"LOCALAPPDATA",
"USERPROFILE",
"XDG_CONFIG_HOME",
] as const;

test.describe("Pi extensions", () => {
test("loads a real extension and completes its UI round trip", async ({
electronApp,
}) => {
const { e2eHome, resourcesPath } = await electronApp.evaluate(
async ({ app }) => ({
e2eHome: process.env.HOME ?? app.getPath("home"),
resourcesPath: process.resourcesPath,
}),
);
const rpcHostPath = path.join(
resourcesPath,
"app.asar.unpacked",
".vite",
"build",
"rpc-host.js",
);
expect(existsSync(rpcHostPath)).toBe(true);

const extensionsDirectory = path.join(
e2eHome,
".pi",
"agent",
"extensions",
);
await mkdir(extensionsDirectory, { recursive: true });
await writeFile(
path.join(extensionsDirectory, "extension-e2e.ts"),
EXTENSION_SOURCE,
);

const previousEnvironment = new Map(
HOME_ENVIRONMENT_KEYS.map((key) => [key, process.env[key]]),
);
for (const key of HOME_ENVIRONMENT_KEYS) {
process.env[key] = e2eHome;
}

const events: PiExtensionEvent[] = [];
let client: PiRpcClient | undefined;

try {
const { createPiRpcClient } = await import(
"@posthog/agent/pi/rpc-client"
);
client = createPiRpcClient({
cliPath: rpcHostPath,
cwd: e2eHome,
projectTrusted: false,
providerOptions: { apiKey: "unused-e2e-key" },
});
client.onEvent((event) => {
if (
event.type === "extension_ui_request" ||
event.type === "extension_error"
) {
events.push(event);
}
});
await client.start();

const prompt = client.prompt("/extension-e2e");

await expect
.poll(() => events)
.toContainEqual(
expect.objectContaining({
type: "extension_ui_request",
method: "setTitle",
title: "Extension E2E",
}),
);
await expect
.poll(() => events)
.toContainEqual(
expect.objectContaining({
type: "extension_ui_request",
method: "setStatus",
statusKey: "extension-e2e",
statusText: "Extension ready",
}),
);
await expect
.poll(() => events)
.toContainEqual(
expect.objectContaining({
type: "extension_ui_request",
method: "setWidget",
widgetKey: "extension-e2e",
widgetLines: ["Extension widget ready"],
}),
);
await expect
.poll(() => events)
.toContainEqual(
expect.objectContaining({
type: "extension_ui_request",
method: "confirm",
title: "Extension confirmation",
message: "Confirm the extension RPC round trip.",
}),
);

const confirmation = events.find(
(event) =>
event.type === "extension_ui_request" && event.method === "confirm",
);
if (!confirmation) {
throw new Error("Extension confirmation request was not emitted");
}

await client.respondToExtensionUI({
type: "extension_ui_response",
id: confirmation.id,
confirmed: true,
});
await prompt;

await expect
.poll(() => events)
.toContainEqual(
expect.objectContaining({
type: "extension_ui_request",
method: "notify",
message: "Extension E2E passed",
notifyType: "info",
}),
);
} finally {
await client?.stop();
for (const [key, value] of previousEnvironment) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
});
});
23 changes: 23 additions & 0 deletions products/desktop/docs/PI-EXTENSIONS.md
Comment thread
marandaneto marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Pi extensions in PostHog Desktop

PostHog Desktop supports Pi extensions and packages in local Pi sessions through Pi's RPC mode. Cloud Pi sessions do not load local extensions.

For installation, discovery, authoring, and security guidance, see the [Pi documentation](https://pi.dev/docs/latest/) and [Pi extension documentation](https://pi.dev/docs/latest/extensions).

## Repository trust

Project-local Pi resources remain disabled until the repository is explicitly trusted. When PostHog Desktop detects project resources, it shows a trust control above the composer.

Trust decisions use Pi's native project trust store. A decision made for the registered main repository also applies to PostHog Desktop's managed worktrees for that repository. Trusting or revoking a repository restarts the local Pi runtime so Pi can rebuild its resource set while preserving the native task session.

Pi extensions execute inside the local Pi subprocess with the current user's permissions. Review a repository's Pi resources before trusting it.

## Desktop RPC behavior

PostHog Desktop supports extension tools, lifecycle hooks, slash commands, custom text messages, and the UI methods represented by Pi's RPC protocol. RPC dialogs, notifications, statuses, text widgets, titles, and editor text updates are rendered using Desktop UI.

Extension UI state is ephemeral. PostHog Desktop does not replay UI requests or maintain a separate snapshot across RPC reconnects or runtime replacement. Extensions that need to restore meaningful state should persist it in the Pi session and construct fresh UI from that state.

PostHog Desktop does not support terminal-only behavior such as arbitrary TUI components, rendering factories, raw terminal input handlers, custom headers or footers, themes, custom tool-call renderers, editor component replacement, editor autocomplete providers, or synchronous reads of the current composer text. Widgets are limited to text lines.

Extensions that support both environments should use Pi's RPC-compatible UI methods in Desktop and keep terminal-specific behavior behind Pi's TUI mode checks.
93 changes: 93 additions & 0 deletions products/desktop/packages/agent/src/pi/rpc-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,42 @@ describe("createPiRpcClient", () => {
).toBeUndefined();
});

it("passes repository trust over the private bootstrap pipe", async () => {
const directory = await mkdtemp(join(tmpdir(), "pi-project-trust-"));
const hostPath = join(directory, "host.mjs");
const capturePath = join(directory, "bootstrap.json");
await writeFile(
hostPath,
`
import { readFileSync, writeFileSync } from "node:fs";

writeFileSync(${JSON.stringify(capturePath)}, readFileSync(3, "utf8"));
process.stdin.resume();
`,
);
const client = createPiRpcClient({
cliPath: hostPath,
cwd: directory,
projectTrusted: true,
providerOptions: { apiKey: "proxy-key" },
});

try {
await client.start();
await vi.waitFor(async () => {
await expect(readFile(capturePath, "utf8")).resolves.toBe(
JSON.stringify({
providerOptions: { apiKey: "proxy-key" },
projectTrusted: true,
}),
);
});
} finally {
await client.stop();
await rm(directory, { recursive: true });
}
});

it("runs the RPC host with Electron's Node mode enabled", async () => {
const directory = await mkdtemp(join(tmpdir(), "pi-electron-node-mode-"));
const hostPath = join(directory, "host.mjs");
Expand Down Expand Up @@ -62,6 +98,63 @@ process.stdin.resume();
}
});

it("intercepts extension UI requests and writes responses on the Pi wire", async () => {
const directory = await mkdtemp(join(tmpdir(), "pi-extension-ui-"));
const hostPath = join(directory, "host.mjs");
const capturePath = join(directory, "response.json");
await writeFile(
hostPath,
`
import { closeSync, writeFileSync } from "node:fs";
import { createInterface } from "node:readline";

closeSync(3);
process.stdout.write(JSON.stringify({
type: "extension_ui_request",
id: "extension-1",
method: "input",
title: "Your name",
}) + "\\n");
createInterface({ input: process.stdin }).on("line", (line) => {
writeFileSync(${JSON.stringify(capturePath)}, line);
});
`,
);
const client = createPiRpcClient({
cliPath: hostPath,
cwd: directory,
providerOptions: { apiKey: "proxy-key" },
});
const request = new Promise<unknown>((resolve) => client.onEvent(resolve));

try {
await client.start();
await expect(request).resolves.toEqual({
type: "extension_ui_request",
id: "extension-1",
method: "input",
title: "Your name",
});
await client.respondToExtensionUI({
type: "extension_ui_response",
id: "extension-1",
value: "Ada",
});
await vi.waitFor(async () => {
await expect(readFile(capturePath, "utf8")).resolves.toBe(
JSON.stringify({
type: "extension_ui_response",
id: "extension-1",
value: "Ada",
}),
);
});
} finally {
await client.stop();
await rm(directory, { recursive: true });
}
});

it("uses the private host channel without changing Pi RPC", async () => {
const directory = await mkdtemp(join(tmpdir(), "pi-host-channel-"));
const hostPath = join(directory, "host.mjs");
Expand Down
Loading
Loading