From 07125d2c02f12228389222727992a900b4b4a1df Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 1 Sep 2026 16:12:23 +0200 Subject: [PATCH] Let Git plugins prepare themselves during installation (#4158) * feat(plugins): support prepared Git installations Let Git plugins declare explicit preparation commands without making package installation the default. Keep installation transactional, make plugin trust explicit, and use one canonical source reference for monorepos. * fix(plugins): parse Windows source references --- docs/plugins.md | 53 ++- .../cli/src/commands/plugin/index.test.ts | 74 +++- packages/cli/src/commands/plugin/index.ts | 23 +- .../cli/src/commands/plugin/scaffold.test.ts | 18 +- packages/cli/src/commands/plugin/scaffold.ts | 363 +----------------- packages/client/src/daemon-client.test.ts | 43 +++ packages/client/src/daemon-client.ts | 12 +- .../protocol/src/messages.plugins.test.ts | 9 +- packages/protocol/src/messages.ts | 1 + .../src/plugin-source-reference.test.ts | 35 ++ .../protocol/src/plugin-source-reference.ts | 47 +++ .../src/server/plugins/index.posix.test.ts | 101 ++++- packages/server/src/server/plugins/index.ts | 45 ++- .../src/server/plugins/managed-source.ts | 12 +- .../src/server/plugins/manifest.test.ts | 32 ++ .../server/src/server/plugins/manifest.ts | 10 +- .../server/src/server/plugins/preparation.ts | 71 ++++ packages/server/src/server/session.ts | 6 +- public-docs/cli.md | 9 +- public-docs/plugins/index.md | 35 +- public-docs/plugins/reference.md | 39 +- 21 files changed, 604 insertions(+), 434 deletions(-) create mode 100644 packages/protocol/src/plugin-source-reference.test.ts create mode 100644 packages/protocol/src/plugin-source-reference.ts create mode 100644 packages/server/src/server/plugins/manifest.test.ts create mode 100644 packages/server/src/server/plugins/preparation.ts diff --git a/docs/plugins.md b/docs/plugins.md index cc59c36373..31070b4b88 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -2,8 +2,9 @@ Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items, composer pills, app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a -subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code; -Paseo does not sandbox it. +subprocess and evaluates the client contribution in the app runtime. + +> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host. ## Install a directory source @@ -47,15 +48,13 @@ my-plugin/ paseo-plugin.json index.ts main.client.tsx - paseo-plugin.d.ts package.json tsconfig.json ``` -Paseo compiles TypeScript and TSX when loading the plugin, so these packages are development dependencies only. -The generated declaration file supplies `@getpaseo/plugin` and `@getpaseo/plugin/server` types until the -SDK is distributed as a public package. Regenerate new plugins with the matching Paseo CLI when the -SDK contract changes. +The generated `package.json` installs `@getpaseo/plugin` and the other host modules as development +dependencies for local typechecking and tests. Paseo compiles TypeScript and TSX and supplies the +runtime modules, so consumers do not install these packages when adding the plugin. ```json { @@ -84,28 +83,52 @@ directory always wins over shorthand resolution. ```bash paseo plugin add owner/repository +paseo plugin add https://gitlab.com/group/repository.git paseo plugin add https://git.example.com/owner/repository.git -paseo plugin add owner/monorepo --path plugins/review +paseo plugin add owner/monorepo:plugins/review paseo plugin add owner/repository --ref main paseo plugin status paseo plugin update review paseo plugin update --all ``` +Append `:relative/path` to the source when the plugin lives below the repository root. + Omitting `--ref` tracks the remote's default branch. A branch passed with `--ref` also tracks; tags and commits stay pinned. `status` fetches tracked refs and reports the installed and available -commits. `update` checks out a new version, validates its manifest, compiles both bundles, and starts -it before changing the configured path. A compilation or startup failure restores the running -version. Removing a Git source deletes Paseo's managed checkout. +commits. Removing a Git source deletes Paseo's managed checkout. -Git installation does not run a package manager or install script. A distributable source plugin -uses Paseo's host-provided modules or commits any other bundled source it needs. Native React Native -dependencies work only when the Paseo app already ships the native module. +### Declare Git preparation + +Most plugins should omit `build`. Use it only when the staged checkout must install a dependency +that Paseo does not provide, generate source or assets, or perform another required preparation +step: + +```json +{ + "id": "review", + "build": [ + ["npm", "ci"], + ["npm", "run", "build"] + ] +} +``` + +`build` is an optional list of argv arrays. Each array must contain at least one non-empty string; +shell command strings are rejected. Paseo starts the executable directly, without a shell, from the +plugin directory in the staged checkout. It never detects lockfiles or chooses a package manager. + +On install and every update, Paseo resolves the exact Git revision and manifest, runs the declared +commands, then validates, compiles, and activates the candidate. It logs each argv command and its +output in the daemon log. If a command fails, the error includes its output, Paseo discards the +candidate, and the existing installed and running version stays untouched. On a remote daemon, all +of this happens on the remote daemon host. Server contributions can write to stdout and stderr with normal Node logging. Paseo adds `[paseo]` entries for loading, ready, stopping, and stopped transitions. Compilation and load failures are recorded as stderr entries before a subprocess exists. Inspect the recent in-memory -tail from the host plugin settings or with `paseo plugin logs `. Reload, disable, and process +tail from the host plugin settings or with `paseo plugin logs `. Git preparation commands are +recorded in `$PASEO_HOME/daemon.log` before a plugin exists, rather than the plugin log tail. Reload, disable, and process failure retain the tail; removing the plugin clears it. Daemon restarts do not retain the tail, but structured copies remain in `$PASEO_HOME/daemon.log`. Plugin output can contain secrets, so do not log credentials or tokens. diff --git a/packages/cli/src/commands/plugin/index.test.ts b/packages/cli/src/commands/plugin/index.test.ts index 53261b2104..11e80480f4 100644 --- a/packages/cli/src/commands/plugin/index.test.ts +++ b/packages/cli/src/commands/plugin/index.test.ts @@ -9,25 +9,44 @@ const getPluginLogs = vi.fn(async () => [ message: "ready", }, ]); +const installDirectoryPlugin = vi.fn(async () => ({ + id: "trusted-plugin", + path: "/plugins/trusted-plugin", + enabled: true, + status: "running" as const, +})); +const installPluginSource = vi.fn(async () => ({ + id: "trusted-plugin", + path: "/plugins/trusted-plugin", + enabled: true, + status: "running" as const, +})); const close = vi.fn(async () => undefined); -const features: { pluginManagement?: boolean; pluginLogs?: boolean } = {}; +const features: { + pluginManagement?: boolean; + pluginLogs?: boolean; + pluginGitManagement?: boolean; +} = {}; vi.mock("../../utils/client.js", () => ({ connectToDaemon: vi.fn(async () => ({ getLastServerInfoMessage: () => ({ features }), listPlugins, getPluginLogs, + installDirectoryPlugin, + installPluginSource, close, })), })); import { render } from "../../output/index.js"; -import { runPluginListCommand, runPluginLogsCommand } from "./index.js"; +import { createPluginCommand, runPluginListCommand, runPluginLogsCommand } from "./index.js"; describe("plugin management commands", () => { beforeEach(() => { features.pluginManagement = false; features.pluginLogs = false; + features.pluginGitManagement = false; vi.clearAllMocks(); }); @@ -63,4 +82,55 @@ describe("plugin management commands", () => { }, ]); }); + + it("makes trust explicit at the plugin add entry point", () => { + const command = createPluginCommand(); + expect( + command.commands.find((subcommand) => subcommand.name() === "install")?.description(), + ).toContain("Trust and install"); + expect(command.helpInformation()).toContain("trusted, unsandboxed plugins"); + }); + + it("prints the trust acknowledgement before installing", async () => { + features.pluginManagement = true; + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const command = createPluginCommand(); + + await command.parseAsync(["install", "/plugins/trusted-plugin"], { from: "user" }); + + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining("Git build commands run unsandboxed on the daemon host"), + ); + expect(installDirectoryPlugin).toHaveBeenCalledWith("/plugins/trusted-plugin", undefined); + stderr.mockRestore(); + }); + + it("folds the legacy --path option into the plugin source reference", async () => { + features.pluginGitManagement = true; + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const command = createPluginCommand(); + + await command.parseAsync(["install", "owner/monorepo", "--path", "plugins/review"], { + from: "user", + }); + + expect(installPluginSource).toHaveBeenCalledWith({ + source: "owner/monorepo:plugins/review", + }); + stderr.mockRestore(); + }); + + it("keeps an absolute monorepo path as one plugin source reference", async () => { + features.pluginGitManagement = true; + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const command = createPluginCommand(); + + await command.parseAsync(["install", "/plugins/monorepo:plugins/review"], { from: "user" }); + + expect(installPluginSource).toHaveBeenCalledWith({ + source: "/plugins/monorepo:plugins/review", + }); + expect(installDirectoryPlugin).not.toHaveBeenCalled(); + stderr.mockRestore(); + }); }); diff --git a/packages/cli/src/commands/plugin/index.ts b/packages/cli/src/commands/plugin/index.ts index c11f1568f6..016938d947 100644 --- a/packages/cli/src/commands/plugin/index.ts +++ b/packages/cli/src/commands/plugin/index.ts @@ -6,6 +6,10 @@ import type { PluginSourceStatusItem, PluginSourceUpdateItem, } from "@getpaseo/protocol/messages"; +import { + formatPluginSourceReference, + parsePluginSourceReference, +} from "@getpaseo/protocol/plugin-source-reference"; import type { CommandOptions, ListResult, OutputSchema, SingleResult } from "../../output/index.js"; import { withOutput } from "../../output/index.js"; import { addJsonAndDaemonHostOptions, addJsonOption } from "../../utils/command-options.js"; @@ -113,6 +117,9 @@ async function install( options: PluginOptions, _command: Command, ): Promise> { + process.stderr.write( + "Trusting plugin code: server code and Git build commands run unsandboxed on the daemon host; client code runs inside Paseo. Dependencies and future updates are part of the codebase you trust.\n", + ); const isExplicitPath = path.isAbsolute(source) || source === "." || @@ -121,17 +128,19 @@ async function install( source.startsWith("../") || source.startsWith(".\\") || source.startsWith("..\\"); - const canUseLegacyDirectoryInstall = isExplicitPath && !options.ref && !options.path; + const hasPluginPathSuffix = parsePluginSourceReference(source).pluginPath !== undefined; + const canUseLegacyDirectoryInstall = + isExplicitPath && !hasPluginPathSuffix && !options.ref && !options.path; + const sourceReference = formatPluginSourceReference(source, options.path); const data = canUseLegacyDirectoryInstall ? await withPluginManagementClient(options.host, (client) => client.installDirectoryPlugin(source, options.id), ) : await withPluginSourceClient(options.host, (client) => client.installPluginSource({ - source, + source: sourceReference, ...(options.id ? { id: options.id } : {}), ...(options.ref ? { ref: options.ref } : {}), - ...(options.path ? { pluginPath: options.path } : {}), }), ); return { type: "single", data, schema: pluginSchema }; @@ -188,7 +197,7 @@ async function remove( } export function createPluginCommand(): Command { - const plugin = new Command("plugin").description("Manage trusted plugins"); + const plugin = new Command("plugin").description("Manage trusted, unsandboxed plugins"); addJsonOption( plugin .command("init") @@ -206,11 +215,11 @@ export function createPluginCommand(): Command { plugin .command("install") .alias("add") - .description("Install a plugin from a directory or Git repository") - .argument("", "Host directory, owner/repo shorthand, or Git URL") + .description("Trust and install a plugin from a directory or Git repository") + .argument("", "Host directory, Git source, or Git source:plugin/path") .option("--id ", "Runtime plugin ID (defaults to paseo-plugin.json id)") .option("--ref ", "Git branch, tag, or commit") - .option("--path ", "Plugin directory within the repository"), + .option("--path ", "Legacy form of the :plugin/path source suffix"), ).action(withOutput(install)); addJsonAndDaemonHostOptions( plugin.command("status").description("Check plugin source updates").argument("[id]"), diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts index 4db422c983..3a97d5dc25 100644 --- a/packages/cli/src/commands/plugin/scaffold.test.ts +++ b/packages/cli/src/commands/plugin/scaffold.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { tmpdir } from "node:os"; import ts from "typescript"; @@ -34,6 +34,22 @@ describe("plugin scaffold", () => { expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual({ id: "hello-plugin", }); + expect(JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"))).toEqual({ + name: "hello-plugin", + private: true, + version: "0.0.0", + scripts: { typecheck: "tsc --noEmit" }, + devDependencies: { + "@getpaseo/plugin": "0.7.0", + "@tanstack/react-query": "^5.90.11", + "@types/react": "~19.2.0", + react: "19.1.0", + "react-native": "0.81.5", + typescript: "^5.9.3", + zod: "^4.4.3", + }, + }); + expect(await readdir(directory)).not.toContain("paseo-plugin.d.ts"); await expect(readFile(path.join(directory, "index.ts"), "utf8")).resolves.toContain( 'from "./main.client"', ); diff --git a/packages/cli/src/commands/plugin/scaffold.ts b/packages/cli/src/commands/plugin/scaffold.ts index abca44d90b..dbeab48cce 100644 --- a/packages/cli/src/commands/plugin/scaffold.ts +++ b/packages/cli/src/commands/plugin/scaffold.ts @@ -1,364 +1,7 @@ import { mkdir, readdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { PluginIdSchema } from "@getpaseo/protocol/messages"; - -const SDK_DECLARATIONS = `declare module "@getpaseo/plugin/server" { - import type { PaseoApi } from "@getpaseo/client"; - import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; - - export interface PluginRpcContract< - InputSchema extends ZodType = ZodType, - OutputSchema extends ZodType = ZodType, - > { - name: string; - input: InputSchema; - output: OutputSchema; - } - - export interface PluginAttachmentItem { - id: string; - identifier: string; - title: string; - subtitle?: string; - url: string; - text: string; - resourceType: string; - } - - export interface PluginAttachmentSearchPayload { - items: PluginAttachmentItem[]; - } - - export interface PluginAttachmentSourceContribution { - id: string; - title: string; - icon: string; - pickerTitle: string; - searchPlaceholder: string; - search: PluginRpcContract; - } - - export interface PluginHandlerContext { - paseo: PaseoApi; - } - - export function defineRpc(definition: { - name: string; - input: InputSchema; - output: OutputSchema; - }): PluginRpcContract; - - export function defineAttachmentSource( - definition: Definition, - ): Definition; - - export const PluginAttachmentItemSchema: import("zod").ZodType; - export const PluginAttachmentSearchPayloadSchema: import("zod").ZodType; -} - -declare module "@getpaseo/plugin/react-native" { - import type { ComponentType, FunctionComponent, ReactNode } from "react"; - - export interface PluginIconProps { - name: string; - size?: number; - color?: string; - } - - export interface ModalProps { - title: string; - icon?: ReactNode; - open: boolean; - onOpenChange(open: boolean): void; - children: ReactNode; - } - - export interface ModalContentProps { - children: ReactNode; - } - - export interface ModalComponent extends FunctionComponent { - Content: ComponentType; - } - - export type ToastVariant = "default" | "info" | "success" | "warning" | "error"; - export interface ToastOptions { - variant?: ToastVariant; - durationMs?: number; - } - export interface ToastApi { - show(message: string, options?: ToastOptions): void; - error(message: string): void; - } - - export const Icon: ComponentType; - export const Modal: ModalComponent; - export function useToast(): ToastApi; -} - -declare module "@getpaseo/plugin" { - import type { ComponentType } from "react"; - import type { PaseoApi } from "@getpaseo/client"; - import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; - import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; - import type { - PluginAttachmentSourceContribution, - PluginHandlerContext, - PluginRpcContract, - } from "@getpaseo/plugin/server"; - - export { - PluginAttachmentItemSchema, - PluginAttachmentSearchPayloadSchema, - defineAttachmentSource, - defineRpc, - type PluginAttachmentItem, - type PluginAttachmentSearchPayload, - type PluginAttachmentSourceContribution, - type PluginHandlerContext, - type PluginRpcContract, - } from "@getpaseo/plugin/server"; - - export interface PluginTheme { - readonly colors: { - readonly surface0: string; - readonly surface1: string; - readonly surface2: string; - readonly border: string; - readonly foreground: string; - readonly foregroundMuted: string; - readonly accent: string; - readonly accentForeground: string; - readonly statusSuccess: string; - readonly statusWarning: string; - readonly statusDanger: string; - }; - } - - export interface PluginHostProps { - theme: PluginTheme; - host: { id: string; label: string }; - layout: { compact: boolean; platform: "ios" | "android" | "web" }; - } - - interface PluginNavigableHostProps extends PluginHostProps { - /** Client-owned navigation. Undefined on older hosts; hide dependent affordances when absent. */ - readonly navigation?: { - readonly openAgent: (input: { readonly agentId: string }) => void; - readonly openWorkspace: (input: { readonly workspaceId: string }) => void; - }; - } - - export interface PluginSurfaceProps extends PluginNavigableHostProps {} - - export interface PluginIconProps { - name: string; - size?: number; - color?: string; - } - - export interface PluginWorkspaceSnapshot { - readonly id: string; - readonly projectId: string; - readonly projectDisplayName: string; - readonly projectRootPath: string; - readonly directory: string; - readonly projectKind: "git" | "non_git" | "directory"; - readonly kind: "directory" | "local_checkout" | "checkout" | "worktree"; - readonly name: string; - readonly title: string | null; - readonly status: "needs_input" | "failed" | "running" | "attention" | "done"; - readonly statusEnteredAt: string | null; - readonly archivingAt: string | null; - readonly diffStat: { readonly additions: number; readonly deletions: number } | null; - } - - export interface PluginAgentSnapshot { - readonly id: string; - readonly workspaceId: string; - readonly provider: string; - readonly status: "initializing" | "idle" | "running" | "error" | "closed"; - readonly createdAt: string; - readonly updatedAt: string; - readonly lastActivityAt: string; - readonly title: string | null; - readonly cwd: string; - readonly model: string | null; - readonly currentModeId: string | null; - readonly thinkingOptionId: string | null; - readonly requiresAttention: boolean; - readonly attentionReason: "finished" | "error" | "permission" | null; - readonly parentAgentId: string | null; - readonly labels: Readonly>; - } - - export interface PluginWorkspacePanelProps extends PluginNavigableHostProps { - context: "workspace"; - workspaceId: string; - } - - export interface PluginAgentPanelProps extends PluginNavigableHostProps { - context: "agent"; - workspaceId: string; - agentId: string; - } - - export interface PluginComposerPillProps extends PluginHostProps { - workspaceId: string; - agentId: string; - } - - export interface PluginComposerPillContribution { - id: string; - title: string; - workspaceId: string; - agentId: string; - Component: ComponentType; - onPress(): void | Promise; - } - - export type PluginPanelLocation = "workspace" | "explorer"; - export interface PluginOpenPanelOptions { location?: PluginPanelLocation; } - export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions { - workspaceId: string; - agentId?: string; - } - - export type PluginWorkspacePanelContribution = - | { id: string; title: string; icon: string; locations?: readonly PluginPanelLocation[]; context: "workspace"; Component: ComponentType } - | { id: string; title: string; icon: string; locations?: readonly PluginPanelLocation[]; context: "agent"; Component: ComponentType }; - - export interface PluginSidebarContribution { - id: string; - title: string; - icon: string; - surface: string; - } - - export interface PluginThemeColors { - background: string; - foreground: string; - raised: string; - control: string; - border: string; - accent?: string; - mutedForeground: string; - ring: string; - } - - export interface PluginThemeContribution { - id: string; - name: string; - appearance: "light" | "dark"; - colors: PluginThemeColors; - } - - export interface PluginSurfaceContribution { - id: string; - Component: ComponentType; - } - - export type PluginTimelineData = null | boolean | number | string | PluginTimelineData[] | { [key: string]: PluginTimelineData }; - export interface PluginTimelineItem { type: "plugin"; kind: string; version: number; data: PluginTimelineData; } - export interface PluginTimelineTransformResult { items: PluginTimelineItem[]; } - export type PluginTimelineTransformerContribution = - ItemType extends AgentTimelineItem["type"] - ? { - id: string; - query: { itemType: ItemType }; - transform(input: { item: Extract }): PluginTimelineTransformResult | undefined; - } - : never; - export interface PluginTimelineItemProps extends PluginHostProps { - agentId: string; - item: { type: "plugin"; kind: string; version: number; data: Data }; - timestamp: Date; - } - export interface PluginTimelineRendererContribution { - kind: string; - version: number; - schema: Schema; - Component: ComponentType>>; - } - - export interface PluginCommandCapabilities { - paseo: PaseoApi; - rpc( - contract: PluginRpcContract, - input: ZodInput, - ): Promise>; - openSurface(id: string): void; - } - - export interface PluginGlobalCommandContext extends PluginCommandCapabilities { - context: "global"; - } - - export interface PluginWorkspaceCommandContext extends PluginCommandCapabilities { - context: "workspace"; - workspace: PluginWorkspaceSnapshot; - openPanel(id: string, options?: PluginOpenPanelOptions): void; - } - - export interface PluginAgentCommandContext extends PluginCommandCapabilities { - context: "agent"; - workspace: PluginWorkspaceSnapshot; - agent: PluginAgentSnapshot; - openPanel(id: string, options?: PluginOpenPanelOptions): void; - } - - export interface PluginClientContext extends PluginCommandCapabilities { - addComposerPill(contribution: PluginComposerPillContribution): PluginCleanup; - openPanel(id: string, options: PluginClientOpenPanelOptions): void; - } - export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup; - - export type PluginCommandCenterItemContribution = - | { id: string; title: string; icon: string; keywords?: readonly string[]; context: "global"; onSelect(context: PluginGlobalCommandContext): void | Promise } - | { id: string; title: string; icon: string; keywords?: readonly string[]; context: "workspace"; onSelect(context: PluginWorkspaceCommandContext): void | Promise } - | { id: string; title: string; icon: string; keywords?: readonly string[]; context: "agent"; onSelect(context: PluginAgentCommandContext): void | Promise }; - - export interface PluginContext { - handle( - contract: PluginRpcContract, - handler: ( - input: ZodOutput, - context: PluginHandlerContext, - ) => ZodInput | Promise>, - ): void; - addSurface(id: string, Component: ComponentType): void; - addSidebarItem(contribution: PluginSidebarContribution): void; - addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void; - addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void; - addClientSide(contribution: PluginClientContribution): void; - addAttachmentSource(contribution: PluginAttachmentSourceContribution): void; - addTheme(contribution: PluginThemeContribution): void; - addTimelineTransformer(contribution: PluginTimelineTransformerContribution): void; - addTimelineRenderer(contribution: PluginTimelineRendererContribution): void; - } - - export type PluginCleanup = () => void | Promise; - export type PluginContribution = (plugin: PluginContext) => PluginCleanup; - - export const Icon: ComponentType; - - export function useRpc( - contract: PluginRpcContract, - ): (input: ZodInput) => Promise>; - - export function usePaseo(): PaseoApi; - - export function useWorkspace( - workspaceId: string, - selector: (workspace: PluginWorkspaceSnapshot) => Selection, - ): Selection | null; - - export function useAgent( - agentId: string, - selector: (agent: PluginAgentSnapshot) => Selection, - ): Selection | null; -} -`; +import { resolveCliVersion } from "../../version.js"; const TSCONFIG = { compilerOptions: { @@ -432,8 +75,7 @@ export async function scaffoldPluginDirectory( version: "0.0.0", scripts: { typecheck: "tsc --noEmit" }, devDependencies: { - "@getpaseo/client": "^0.4.0", - "@getpaseo/protocol": "^0.6.1", + "@getpaseo/plugin": resolveCliVersion(), "@tanstack/react-query": "^5.90.11", "@types/react": "~19.2.0", react: "19.1.0", @@ -446,7 +88,6 @@ export async function scaffoldPluginDirectory( ["paseo-plugin.json", `${JSON.stringify({ id }, null, 2)}\n`], ["package.json", `${JSON.stringify(packageJson, null, 2)}\n`], ["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`], - ["paseo-plugin.d.ts", SDK_DECLARATIONS], ["index.ts", ENTRY], ["main.client.tsx", CLIENT_SURFACE], ]); diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index f2ed2125a8..f7e1af9abe 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -1988,6 +1988,49 @@ test("file context action RPCs correlate success and error responses", async () }); }); +test("serializes plugin source suffixes through the legacy path field", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_plugin_source", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const installPromise = client.installPluginSource({ + source: "owner/repository:plugins/review", + }); + const request = parseSentFrame(mock.sent.at(-1)); + expect(request).toEqual({ + type: "plugin.source.install.request", + requestId: expect.any(String), + source: "owner/repository", + pluginPath: "plugins/review", + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "plugin.source.install.response", + payload: { + requestId: request.requestId, + plugin: { + id: "review", + path: "/plugins/review", + enabled: true, + status: "running", + }, + }, + }), + ); + + await expect(installPromise).resolves.toMatchObject({ id: "review", status: "running" }); +}); + test("a connection loss rejects an in-flight file context action", async () => { const mock = createMockTransport(); const client = new DaemonClient({ diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 0a194da2f9..238b334611 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1,6 +1,7 @@ import type { z } from "zod"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification"; +import { parsePluginSourceReference } from "@getpaseo/protocol/plugin-source-reference"; import { AgentCreateFailedStatusPayloadSchema, AgentCreatedStatusPayloadSchema, @@ -4971,12 +4972,19 @@ export class DaemonClient { source: string; id?: string; ref?: string; - pluginPath?: string; }): Promise { const requestId = this.createRequestId(); + const reference = parsePluginSourceReference(input.source); const payload = await this.sendCorrelatedSessionRequest({ requestId, - message: { type: "plugin.source.install.request", requestId, ...input }, + message: { + type: "plugin.source.install.request", + requestId, + source: reference.source, + ...(reference.pluginPath ? { pluginPath: reference.pluginPath } : {}), + ...(input.id ? { id: input.id } : {}), + ...(input.ref ? { ref: input.ref } : {}), + }, responseType: "plugin.source.install.response", }); return payload.plugin; diff --git a/packages/protocol/src/messages.plugins.test.ts b/packages/protocol/src/messages.plugins.test.ts index 5c5efb1e1b..d26aa18f6c 100644 --- a/packages/protocol/src/messages.plugins.test.ts +++ b/packages/protocol/src/messages.plugins.test.ts @@ -53,8 +53,15 @@ describe("plugin protocol compatibility", () => { SessionInboundMessageSchema.parse({ type: "plugin.source.install.request", requestId: "request-install", - source: "owner/repository", + source: "owner/repository:plugins/review", ref: "main", + }).type, + ).toBe("plugin.source.install.request"); + expect( + SessionInboundMessageSchema.parse({ + type: "plugin.source.install.request", + requestId: "request-install-old-client", + source: "owner/repository", pluginPath: "plugins/review", }).type, ).toBe("plugin.source.install.request"); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 35dd1e8ad0..93f6be9ef9 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1465,6 +1465,7 @@ export const PluginSourceInstallRequestSchema = z.object({ source: z.string().min(1), id: PluginIdSchema.optional(), ref: z.string().min(1).optional(), + // COMPAT(plugin-source-path): accepted for v0.7 clients; remove after 2027-09-01. pluginPath: z.string().min(1).optional(), }); diff --git a/packages/protocol/src/plugin-source-reference.test.ts b/packages/protocol/src/plugin-source-reference.test.ts new file mode 100644 index 0000000000..78c455f83d --- /dev/null +++ b/packages/protocol/src/plugin-source-reference.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { parsePluginSourceReference } from "./plugin-source-reference.js"; + +describe("plugin source references", () => { + it.each([ + ["owner/repository", "owner/repository", undefined], + ["owner/repository:plugins/review", "owner/repository", "plugins/review"], + [ + "https://example.test:8443/owner/repository.git", + "https://example.test:8443/owner/repository.git", + undefined, + ], + [ + "https://example.test:8443/owner/repository.git:plugins/review", + "https://example.test:8443/owner/repository.git", + "plugins/review", + ], + ["git@example.test:owner/repository.git", "git@example.test:owner/repository.git", undefined], + [ + "git@example.test:owner/repository.git:plugins/review", + "git@example.test:owner/repository.git", + "plugins/review", + ], + ["file:///D:/plugins/repository", "file:///D:/plugins/repository", undefined], + [ + "file:///D:/plugins/repository:plugins/review", + "file:///D:/plugins/repository", + "plugins/review", + ], + ["D:\\plugins\\repository", "D:\\plugins\\repository", undefined], + ["D:\\plugins\\repository:plugins/review", "D:\\plugins\\repository", "plugins/review"], + ])("parses %s", (reference, source, pluginPath) => { + expect(parsePluginSourceReference(reference)).toEqual({ source, pluginPath }); + }); +}); diff --git a/packages/protocol/src/plugin-source-reference.ts b/packages/protocol/src/plugin-source-reference.ts new file mode 100644 index 0000000000..d50abb7d69 --- /dev/null +++ b/packages/protocol/src/plugin-source-reference.ts @@ -0,0 +1,47 @@ +export interface PluginSourceReference { + source: string; + pluginPath: string | undefined; +} + +export function parsePluginSourceReference(reference: string): PluginSourceReference { + const separator = reference.lastIndexOf(":"); + if (separator === -1) return { source: reference, pluginPath: undefined }; + + const pluginPath = reference.slice(separator + 1); + if (!isPortableRelativePluginPath(pluginPath)) { + return { source: reference, pluginPath: undefined }; + } + + const scheme = reference.indexOf("://"); + if (scheme !== -1) { + const pathStart = reference.indexOf("/", scheme + 3); + if (pathStart === -1 || separator < pathStart) { + return { source: reference, pluginPath: undefined }; + } + } else { + const scpSeparator = reference.match(/^[^/@\s]+@[^:\s]+:/)?.[0].length; + if (scpSeparator !== undefined && separator === scpSeparator - 1) { + return { source: reference, pluginPath: undefined }; + } + } + + return { source: reference.slice(0, separator), pluginPath }; +} + +export function formatPluginSourceReference( + source: string, + pluginPath: string | undefined, +): string { + if (pluginPath === undefined) return source; + if (!isPortableRelativePluginPath(pluginPath)) { + throw new Error("Plugin path must be a portable path relative to the repository"); + } + return `${source}:${pluginPath}`; +} + +function isPortableRelativePluginPath(pluginPath: string): boolean { + if (pluginPath === ".") return true; + if (!pluginPath || pluginPath.startsWith("/") || pluginPath.startsWith("\\")) return false; + if (/^[A-Za-z]:/.test(pluginPath) || pluginPath.includes(":")) return false; + return pluginPath.split(/[\\/]/).every((part) => part !== "" && part !== "." && part !== ".."); +} diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index d3618f9d74..f9222b72ab 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -278,12 +278,12 @@ describe("PluginService", () => { await service.start(); await expect( - service.installSource({ source: repository, pluginPath: "plugins/review" }), + service.installSource({ source: `${repository}:plugins/review` }), ).resolves.toMatchObject({ id: "local-monorepo", path: pluginDirectory, status: "running" }); await service.stopAllPlugins(); }, 20_000); - it("keeps the running commit when a Git update fails validation", async () => { + it("keeps the running commit when a Git update build command fails", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); roots.push(home); const repository = await mkdtemp(path.join(tmpdir(), "paseo-plugin-repository-")); @@ -313,6 +313,15 @@ describe("PluginService", () => { const installedPath = installed.path; const installedCommit = installed.commit; + await writeFile( + path.join(repository, "paseo-plugin.json"), + JSON.stringify({ + id: "git-update", + build: [ + [process.execPath, "-e", 'process.stderr.write("build exploded") ; process.exit(1)'], + ], + }), + ); await writeFile( path.join(repository, "index.ts"), 'export default function contribute(plugin: unknown) { void plugin; throw new Error("broken update"); }', @@ -321,6 +330,7 @@ describe("PluginService", () => { await runGitCommand(["commit", "-m", "broken update"], { cwd: repository }); await expect(service.updateSources("git-update")).rejects.toThrow(); + expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]); expect(service.catalog()).toEqual([ expect.objectContaining({ id: "git-update", clientBundle: expect.any(String) }), ]); @@ -335,6 +345,91 @@ describe("PluginService", () => { await service.stopAllPlugins(); }, 30_000); + it("runs Git build commands in staging before validation and activation on install and update", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const repository = await mkdtemp(path.join(tmpdir(), "paseo-plugin-repository-")); + roots.push(repository); + await runGitCommand(["init", "-b", "main"], { cwd: repository }); + await runGitCommand(["config", "user.name", "Paseo Tests"], { cwd: repository }); + await runGitCommand(["config", "user.email", "paseo@example.test"], { cwd: repository }); + await writeFile( + path.join(repository, "paseo-plugin.json"), + JSON.stringify({ + id: "prepared-git-plugin", + build: [ + [ + process.execPath, + "-e", + 'require("node:fs").writeFileSync(process.argv[1], "prepared")', + "build-marker;touch shell-injection", + ], + ], + }), + ); + await writeFile(path.join(repository, "index.ts"), "export default () => () => {};\n"); + await runGitCommand(["add", "-A"], { cwd: repository }); + await runGitCommand(["commit", "-m", "initial"], { cwd: repository }); + + const events: string[] = []; + const running = new Set(); + const runtime: TestPluginRuntime = { + catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), + invoke: async () => undefined, + getLogs: () => [], + clearLogs: () => undefined, + validatePlugin: async (directory) => { + events.push( + `validate:${await readFile(path.join(directory, "build-marker;touch shell-injection"), "utf8")}`, + ); + }, + startPlugin: async (pluginId) => { + events.push("start"); + running.add(pluginId); + }, + stopPluginById: async (pluginId) => running.delete(pluginId), + stopAll: async () => running.clear(), + subscribe: () => () => undefined, + bindPaseoSessionHost: () => undefined, + }; + const service = createService( + home, + {}, + { + runtime, + managedSources: new ManagedPluginSources(home), + }, + ); + await service.start(); + + const installed = await service.installSource({ source: pathToFileURL(repository).href }); + + expect(events).toEqual(["validate:prepared", "start"]); + await expect(stat(path.join(installed.path, "shell-injection"))).rejects.toThrow(); + + await writeFile( + path.join(repository, "paseo-plugin.json"), + JSON.stringify({ + id: "prepared-git-plugin", + build: [ + [ + process.execPath, + "-e", + 'require("node:fs").writeFileSync("build-marker;touch shell-injection", "updated")', + ], + ], + }), + ); + await runGitCommand(["add", "-A"], { cwd: repository }); + await runGitCommand(["commit", "-m", "prepared update"], { cwd: repository }); + + await expect(service.updateSources("prepared-git-plugin")).resolves.toEqual([ + expect.objectContaining({ id: "prepared-git-plugin", updated: true }), + ]); + expect(events).toEqual(["validate:prepared", "start", "validate:updated", "start"]); + await service.stopAllPlugins(); + }, 30_000); + it("activates an update when the enabled plugin previously failed to start", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); roots.push(home); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index 844dd8db5a..df987c903b 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -9,9 +9,11 @@ import { type PluginSourceStatusItem, type PluginSourceUpdateItem, } from "@getpaseo/protocol/messages"; +import { parsePluginSourceReference } from "@getpaseo/protocol/plugin-source-reference"; import type { DaemonConfigStore } from "../daemon-config-store.js"; import { type ManagedPluginCandidate, ManagedPluginSources } from "./managed-source.js"; import { readPluginManifest } from "./manifest.js"; +import { runPluginBuild } from "./preparation.js"; import { PluginRuntime } from "./runtime.js"; interface PluginRuntimePort { @@ -165,27 +167,36 @@ export class PluginService { source: string; id?: string; ref?: string; - pluginPath?: string; }): Promise { - const directory = path.resolve(input.source); - const info = await stat(directory).catch(() => null); + const directDirectory = path.resolve(input.source); + const directInfo = await stat(directDirectory).catch(() => null); + const reference = directInfo?.isDirectory() + ? { source: input.source, pluginPath: undefined } + : parsePluginSourceReference(input.source); + const directory = path.resolve(reference.source); + const info = directInfo?.isDirectory() ? directInfo : await stat(directory).catch(() => null); if (info?.isDirectory()) { if (input.ref) throw new Error("Plugin --ref is only valid for Git sources"); - const pluginDirectory = resolveLocalPluginPath(directory, input.pluginPath); + const pluginDirectory = resolveLocalPluginPath(directory, reference.pluginPath); return this.installDirectory({ path: pluginDirectory, id: input.id }); } const managedSources = this.requireManagedSources(); return this.enqueue(async () => { - let candidate = await managedSources.prepareInstall(input); - const pluginId = PluginIdSchema.parse(input.id ?? candidate.defaultId); - if (this.configStore.get().plugins?.[pluginId]) { - await managedSources.discard(candidate); - throw new Error( - `Plugin ID "${pluginId}" is already configured; choose another ID with --id`, - ); - } - candidate = await managedSources.place(pluginId, candidate); + let candidate = await managedSources.prepareInstall({ + ...input, + source: reference.source, + pluginPath: reference.pluginPath, + }); + let pluginId: string; try { + await runPluginBuild(candidate.directory, candidate.build, this.logger); + pluginId = PluginIdSchema.parse(input.id ?? candidate.defaultId); + if (this.configStore.get().plugins?.[pluginId]) { + throw new Error( + `Plugin ID "${pluginId}" is already configured; choose another ID with --id`, + ); + } + candidate = await managedSources.place(pluginId, candidate); await this.validateCandidate(candidate); } catch (error) { await managedSources.discard(candidate); @@ -382,8 +393,10 @@ export class PluginService { updated: false, }; } - const candidate = await managedSources.place(pluginId, prepared.candidate); + let candidate = prepared.candidate; try { + await runPluginBuild(candidate.directory, candidate.build, this.logger); + candidate = await managedSources.place(pluginId, candidate); await this.validateCandidate(candidate); } catch (error) { await managedSources.discard(candidate); @@ -481,11 +494,11 @@ export class PluginService { function resolveLocalPluginPath(directory: string, pluginPath: string | undefined): string { if (!pluginPath) return directory; - if (path.isAbsolute(pluginPath)) throw new Error("Plugin --path must be relative to the source"); + if (path.isAbsolute(pluginPath)) throw new Error("Plugin path must be relative to the source"); const pluginDirectory = path.resolve(directory, pluginPath); const relative = path.relative(directory, pluginDirectory); const escapesSource = relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); - if (escapesSource) throw new Error("Plugin --path must stay inside the source directory"); + if (escapesSource) throw new Error("Plugin path must stay inside the source directory"); return pluginDirectory; } diff --git a/packages/server/src/server/plugins/managed-source.ts b/packages/server/src/server/plugins/managed-source.ts index 5142028999..844289eb22 100644 --- a/packages/server/src/server/plugins/managed-source.ts +++ b/packages/server/src/server/plugins/managed-source.ts @@ -7,6 +7,7 @@ import { PluginIdSchema, type PluginSourceStatusItem } from "@getpaseo/protocol/ import { runGitCommand } from "../../utils/run-git-command.js"; import { ensurePrivateDirectory, writePrivateFileAtomicSync } from "../private-files.js"; import { readPluginManifest } from "./manifest.js"; +import type { PluginManifest } from "./manifest.js"; const GIT_TIMEOUT_MS = 120_000; const GIT_ENV = { GIT_TERMINAL_PROMPT: "0" } as const; @@ -27,6 +28,7 @@ const ManagedPluginRecordsSchema = z.record(PluginIdSchema, ManagedPluginRecordS export interface ManagedPluginRecord extends z.infer {} export interface ManagedPluginCandidate { + build: PluginManifest["build"]; defaultId: string; directory: string; record: ManagedPluginRecord; @@ -76,8 +78,9 @@ export class ManagedPluginSources { await checkout(checkoutRoot, resolution.commit); const directory = path.resolve(checkoutRoot, pluginPath); assertPluginPath(checkoutRoot, directory); - const { id: defaultId } = await readPluginManifest(directory); + const { id: defaultId, build } = await readPluginManifest(directory); return { + build, defaultId, directory, record: { @@ -207,8 +210,9 @@ export class ManagedPluginSources { await checkout(checkoutRoot, commit); const directory = path.resolve(checkoutRoot, record.pluginPath); assertPluginPath(checkoutRoot, directory); - const { id: defaultId } = await readPluginManifest(directory); + const { id: defaultId, build } = await readPluginManifest(directory); return { + build, defaultId, directory, versionRoot: stagingRoot, @@ -259,10 +263,10 @@ function redactRemoteCredentials(remote: string): string { function normalizePluginPath(pluginPath: string | undefined): string { if (!pluginPath || pluginPath === ".") return "."; if (path.isAbsolute(pluginPath)) - throw new Error("Plugin --path must be relative to the repository"); + throw new Error("Plugin path must be relative to the repository"); const normalized = path.normalize(pluginPath); if (normalized === ".." || normalized.startsWith(`..${path.sep}`)) { - throw new Error("Plugin --path must stay inside the repository"); + throw new Error("Plugin path must stay inside the repository"); } return normalized; } diff --git a/packages/server/src/server/plugins/manifest.test.ts b/packages/server/src/server/plugins/manifest.test.ts new file mode 100644 index 0000000000..07328c95b9 --- /dev/null +++ b/packages/server/src/server/plugins/manifest.test.ts @@ -0,0 +1,32 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { readPluginManifest } from "./manifest.js"; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); +}); + +describe("plugin manifest", () => { + it("accepts only non-empty argv arrays for build commands", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + const manifest = path.join(directory, "paseo-plugin.json"); + + await writeFile( + manifest, + JSON.stringify({ id: "prepared", build: [["pnpm", "install", "--frozen-lockfile"]] }), + ); + await expect(readPluginManifest(directory)).resolves.toMatchObject({ + build: [["pnpm", "install", "--frozen-lockfile"]], + }); + + for (const build of [[], [[]], [["pnpm", ""]], [["pnpm", 1]], "pnpm install"]) { + await writeFile(manifest, JSON.stringify({ id: "prepared", build })); + await expect(readPluginManifest(directory)).rejects.toThrow(); + } + }); +}); diff --git a/packages/server/src/server/plugins/manifest.ts b/packages/server/src/server/plugins/manifest.ts index 540f3540ea..b721f395d3 100644 --- a/packages/server/src/server/plugins/manifest.ts +++ b/packages/server/src/server/plugins/manifest.ts @@ -4,7 +4,15 @@ import { z } from "zod"; import { PluginIdSchema } from "@getpaseo/protocol/messages"; const MANIFEST_FILENAME = "paseo-plugin.json"; -const PluginManifestSchema = z.object({ id: PluginIdSchema }).strict(); +const PluginBuildCommandSchema = z + .array(z.string().refine((argument) => argument.trim().length > 0)) + .min(1); +const PluginManifestSchema = z + .object({ + id: PluginIdSchema, + build: z.array(PluginBuildCommandSchema).min(1).optional(), + }) + .strict(); export type PluginManifest = z.infer; diff --git a/packages/server/src/server/plugins/preparation.ts b/packages/server/src/server/plugins/preparation.ts new file mode 100644 index 0000000000..1e9b4aabef --- /dev/null +++ b/packages/server/src/server/plugins/preparation.ts @@ -0,0 +1,71 @@ +import { spawn } from "node:child_process"; +import type pino from "pino"; +import type { PluginManifest } from "./manifest.js"; + +const OUTPUT_LIMIT = 64 * 1024; + +export async function runPluginBuild( + directory: string, + commands: PluginManifest["build"], + logger: pino.Logger, +): Promise { + for (const command of commands ?? []) { + logger.info({ directory, command }, "Running plugin build command"); + const result = await run(command, directory); + if (result.stdout) + logger.info({ directory, command, output: result.stdout }, "Plugin build stdout"); + if (result.stderr) + logger.info({ directory, command, output: result.stderr }, "Plugin build stderr"); + if (result.error) { + throw new Error( + `Plugin build command failed to start: ${formatCommand(command)}\n${result.error.message}`, + ); + } + if (result.exitCode !== 0) { + throw new Error( + `Plugin build command failed (exit ${result.exitCode}): ${formatCommand(command)}${formatOutput(result)}`, + ); + } + } +} + +function run(command: string[], directory: string): Promise { + return new Promise((resolve) => { + const executable = command[0]!; + const arguments_ = command.slice(1); + const child = spawn(executable, arguments_, { cwd: directory, shell: false }); + let stdout = ""; + let stderr = ""; + let error: Error | undefined; + child.stdout.on("data", (chunk: Buffer | string) => { + stdout = appendOutput(stdout, chunk.toString()); + }); + child.stderr.on("data", (chunk: Buffer | string) => { + stderr = appendOutput(stderr, chunk.toString()); + }); + child.once("error", (spawnError) => { + error = spawnError; + }); + child.once("close", (exitCode) => resolve({ exitCode, stdout, stderr, error })); + }); +} + +function appendOutput(current: string, next: string): string { + return `${current}${next}`.slice(-OUTPUT_LIMIT); +} + +function formatCommand(command: string[]): string { + return command.map((argument) => JSON.stringify(argument)).join(" "); +} + +function formatOutput(result: CommandResult): string { + const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + return output ? `\n${output}` : ""; +} + +interface CommandResult { + exitCode: number | null; + stdout: string; + stderr: string; + error?: Error; +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 21a134f54d..9c18299fcd 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -4,6 +4,7 @@ import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises"; import { basename, resolve, sep } from "path"; import { homedir } from "node:os"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; +import { formatPluginSourceReference } from "@getpaseo/protocol/plugin-source-reference"; import { serializeAgentStreamEvent, type AgentSnapshotPayload, @@ -479,7 +480,6 @@ export interface SessionOptions { source: string; id?: string; ref?: string; - pluginPath?: string; }): Promise; statusSources( pluginId?: string, @@ -2109,10 +2109,10 @@ export class Session { if (!this.pluginRuntime) throw new Error("Plugin service is unavailable"); return this.pluginRuntime .installSource({ - source: msg.source, + // COMPAT(plugin-source-path): accepted for v0.7 clients; remove after 2027-09-01. + source: formatPluginSourceReference(msg.source, msg.pluginPath), ...(msg.id ? { id: msg.id } : {}), ...(msg.ref ? { ref: msg.ref } : {}), - ...(msg.pluginPath ? { pluginPath: msg.pluginPath } : {}), }) .then((plugin) => { this.emit({ diff --git a/public-docs/cli.md b/public-docs/cli.md index 4b5b23d4cd..c002654b27 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -144,13 +144,16 @@ The output includes each script's lifecycle and supervised terminal ID. Services ## Plugins +> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugin server code and Git preparation commands run unsandboxed with the daemon user's access on the daemon host; client contributions run inside Paseo. Dependencies and future updates are part of that decision. With `--host`, commands run on the remote daemon host. + Create and manage trusted plugins on a daemon: ```bash paseo plugin init /absolute/path/to/plugin paseo plugin install /absolute/path/to/plugin paseo plugin add owner/repository -paseo plugin add https://git.example.com/owner/repository.git --ref main +paseo plugin add https://gitlab.com/group/repository.git --ref main +paseo plugin add owner/monorepo:plugins/review paseo plugin status paseo plugin update my-plugin paseo plugin update --all @@ -162,8 +165,8 @@ paseo plugin enable my-plugin paseo plugin remove my-plugin ``` -GitHub shorthand checks an existing host directory first. Use `--path ` for a plugin in -a monorepo. `paseo plugin logs ` returns the plugin's recent daemon-side stdout and stderr. Add `--json` for +GitHub shorthand checks an existing host directory first. Append `:` for a plugin in a +monorepo. `paseo plugin logs ` returns the plugin's recent daemon-side stdout and stderr. Add `--json` for structured entries or `--host ` for another daemon. See the [Plugin reference](/docs/plugins/reference) for installation, trust, lifecycle, and log-retention behavior. diff --git a/public-docs/plugins/index.md b/public-docs/plugins/index.md index 22112fba47..95b790e03b 100644 --- a/public-docs/plugins/index.md +++ b/public-docs/plugins/index.md @@ -16,7 +16,7 @@ surfaces and their current status. Paseo plugins add native workspace panels, composer pills, Command Center items, global surfaces, app themes, daemon behavior, and composer attachment sources. They run on every Paseo client connected to the host, including mobile. -Plugins are trusted local code. Install only code you trust: backend code runs unsandboxed with access to the daemon machine, and client contributions run inside the Paseo app. +> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Server code and Git preparation commands run unsandboxed with the daemon user's access on the daemon host; client contributions run inside Paseo. Dependencies and future updates are part of that decision. With `--host`, commands run on the remote daemon host. On the target host, open **Settings → Plugins** and turn on **Enable plugins**. This is the global switch for every configured plugin on that daemon. @@ -38,7 +38,10 @@ cd /absolute/path/to/workspace-plugin npm install ``` -`init` creates a strict TypeScript project. It does not run the package manager. `index.ts` registers contributions; client UI lives in `*.client.tsx` files. +`init` creates a strict TypeScript project. It does not run the package manager. `npm install` +installs development dependencies for local typechecking and tests. Paseo supplies the plugin SDK, +React, React Native, TanStack Query, and Zod at runtime; plugins do not need a `build` hook for these +modules. `index.ts` registers contributions; client UI lives in `*.client.tsx` files. Plugins run on desktop, browser, iOS, and Android. Paseo ships several themes. Color every `Text` from `theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`. Hardcoded black text fails in dark themes. @@ -123,11 +126,14 @@ To install a plugin published through GitHub or another Git host: ```bash paseo plugin add owner/repository +paseo plugin add https://gitlab.com/group/repository.git paseo plugin add https://git.example.com/owner/repository.git -paseo plugin add owner/monorepo --path plugins/workspace +paseo plugin add owner/monorepo:plugins/workspace paseo plugin add owner/repository --ref main ``` +Append `:relative/path` to the source when the plugin lives below the repository root. + An omitted `--ref` tracks the default branch. Explicit branches track updates; tags and commits are pinned. Check and apply updates with: @@ -137,9 +143,26 @@ paseo plugin update workspace-plugin paseo plugin update --all ``` -Paseo validates and compiles the new commit before replacing a running version. If startup fails, -the previous version is restored. Git installation runs no package manager or install scripts, so -published plugins must use Paseo's host-provided modules or include the source they bundle. +Most plugins should omit `build`. Paseo compiles TypeScript and TSX and supplies its runtime modules. +Declare preparation only when the staged checkout must install another dependency, generate source, +or perform another required build step: + +```json +{ + "id": "workspace-plugin", + "build": [ + ["npm", "ci"], + ["npm", "run", "build"] + ] +} +``` + +Each `build` entry is a non-empty argv array, executed directly without a shell from the staged +plugin directory. Paseo never chooses a package manager or infers commands from lockfiles. On +install and update it resolves the exact commit, runs these commands, then validates, compiles, and +activates the candidate. A failed command discards the candidate and keeps the installed/running +version. The daemon log records the exact argv and output; `--host` runs them on the remote daemon +host. ## Edit and reload diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index c9b1f402b9..155f90f7e4 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -29,7 +29,6 @@ my-plugin/ paseo-plugin.json index.ts main.client.tsx - paseo-plugin.d.ts package.json tsconfig.json ``` @@ -42,9 +41,9 @@ The required root manifest is `paseo-plugin.json`. It contains the default plugi The entry point is `index.ts` at the plugin root. Plugin, surface, sidebar-item, workspace-panel, Command Center item, and attachment-source IDs start with a lowercase letter and contain lowercase letters, numbers, or hyphens. -The generated declaration file supplies `@getpaseo/plugin`, `@getpaseo/plugin/react-native`, and -`@getpaseo/plugin/server` types for local typechecking. Paseo supplies the runtime modules. -Regenerate a fresh project with the matching CLI when the plugin contract changes. +The generated `package.json` installs `@getpaseo/plugin` and the other host modules as development +dependencies for local typechecking and tests. Paseo supplies their runtime instances. Consumers do +not install them when adding the plugin. Add runtime-specific files as the plugin grows: @@ -883,7 +882,7 @@ paseo plugin install /absolute/path/to/plugin paseo plugin install /absolute/path/to/plugin --id another-runtime-id paseo plugin add owner/repository paseo plugin add https://git.example.com/owner/repository.git --ref main -paseo plugin add owner/monorepo --path plugins/review +paseo plugin add owner/monorepo:plugins/review paseo plugin status [id] paseo plugin update paseo plugin update --all @@ -899,10 +898,32 @@ Pass `--host ` to management commands when the target is not the CLI's defa never deletes a directory source; it deletes the managed checkout for a Git source. The install-time `--id` is the runtime ID and allows the same directory or repository to be installed more than once. -An existing directory wins over `owner/repository` GitHub shorthand. Omit `--ref` to track the -default branch. Explicit branches track updates; tags and commits stay pinned. Git installation -runs no package manager and no install scripts. `update` validates and compiles the candidate before -activation, then restores the installed commit if startup fails. +> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Server code and Git preparation commands run unsandboxed with the daemon user's access on the daemon host; client contributions run inside Paseo. Dependencies and future updates are part of that decision. With `--host`, commands run on the remote daemon host. + +An existing directory wins over `owner/repository` GitHub shorthand. Append `:relative/path` when +the plugin lives below the repository root. Omit `--ref` to track the default branch. Explicit +branches track updates; tags and commits stay pinned. + +Most plugins should omit `build`. Use it only when the staged checkout must install a dependency +that Paseo does not provide, generate source or assets, or perform another required preparation +step: + +```json +{ + "id": "review", + "build": [ + ["npm", "ci"], + ["npm", "run", "build"] + ] +} +``` + +`build` is a list of non-empty argv arrays. Paseo runs each executable directly, without a shell, +from the staged plugin directory after resolving the exact commit and manifest. It never infers a +package manager or commands from lockfiles. Install and update both run `build` before validation, +compilation, activation, or replacement. A failing command reports its output, discards the +candidate, and leaves the installed/running version intact. The daemon log records each command and +output; with `--host`, execution is on that daemon host. Run `npm run typecheck` before install or reload. Never edit the daemon config directly.