Add managed local plugin lifecycle (#3222)
* feat(plugins): add local plugin surfaces and RPCs Keep plugin execution scoped to its daemon while allowing one local TypeScript entry point to contribute validated RPCs and native client surfaces. Paseo owns host selection, navigation chrome, and per-installation query caches. * feat(plugins): add Linear attachment source * fix(nix): refresh npm dependency hash * test(server): exclude internal sessions from Hub counts * test(server): distinguish Hub execution sessions * feat(plugins): make local plugins explicitly opt-in Keep plugin loading dormant until configuration enables it, and collect unsupported examples under one directory. * refactor(plugins): establish host boundaries * fix(nix): refresh plugin dependency hash * fix(plugins): complete packaging and startup rollback * feat(plugins): add managed local lifecycle Make trusted local plugins recoverable and authorable through daemon-owned lifecycle operations, host settings, and a capability-gated CLI. Reloads fully stop the previous plugin before starting new code, while generated projects provide strict TSX checking without changing runtime dependency ownership. * fix(nix): refresh plugin dependency hash
This commit is contained in:
@@ -310,6 +310,50 @@ describe("shared messages attachments", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves neutral external-resource presentation on text attachments", () => {
|
||||
const parsed = SendAgentMessageRequestSchema.parse({
|
||||
type: "send_agent_message_request",
|
||||
requestId: "req-external-resource",
|
||||
agentId: "agent-1",
|
||||
text: "Implement this",
|
||||
attachments: [
|
||||
{
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: "ENG-123 Plugin attachments",
|
||||
text: "Linear issue ENG-123: Plugin attachments",
|
||||
externalResource: {
|
||||
provider: "linear",
|
||||
providerLabel: "Linear issue",
|
||||
resourceType: "issue",
|
||||
id: "issue-uuid",
|
||||
identifier: "ENG-123",
|
||||
title: "Plugin attachments",
|
||||
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.attachments).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: "ENG-123 Plugin attachments",
|
||||
text: "Linear issue ENG-123: Plugin attachments",
|
||||
externalResource: {
|
||||
provider: "linear",
|
||||
providerLabel: "Linear issue",
|
||||
resourceType: "issue",
|
||||
id: "issue-uuid",
|
||||
identifier: "ENG-123",
|
||||
title: "Plugin attachments",
|
||||
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps known firstAgentContext attachments and drops unknown ones", () => {
|
||||
const parsed = CreatePaseoWorktreeRequestSchema.parse({
|
||||
type: "create_paseo_worktree_request",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MutableDaemonConfigSchema,
|
||||
SessionInboundMessageSchema,
|
||||
SessionOutboundMessageSchema,
|
||||
StatusMessageSchema,
|
||||
} from "./messages.js";
|
||||
|
||||
describe("plugin protocol compatibility", () => {
|
||||
it("keeps old directory plugin config valid when enabled is absent", () => {
|
||||
const config = MutableDaemonConfigSchema.parse({
|
||||
mcp: { injectIntoAgents: true },
|
||||
plugins: { example: { source: "directory", path: "/plugins/example" } },
|
||||
});
|
||||
|
||||
expect(config.plugins?.example?.enabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses namespaced management request and response pairs", () => {
|
||||
expect(
|
||||
SessionInboundMessageSchema.parse({
|
||||
type: "plugin.directory.install.request",
|
||||
requestId: "request-1",
|
||||
path: "/plugins/example",
|
||||
id: "example-work",
|
||||
}).type,
|
||||
).toBe("plugin.directory.install.request");
|
||||
expect(
|
||||
SessionInboundMessageSchema.parse({
|
||||
type: "plugin.directory.inspect.request",
|
||||
requestId: "request-0",
|
||||
path: "/plugins/example",
|
||||
}).type,
|
||||
).toBe("plugin.directory.inspect.request");
|
||||
expect(
|
||||
SessionOutboundMessageSchema.parse({
|
||||
type: "plugin.directory.install.response",
|
||||
payload: {
|
||||
requestId: "request-1",
|
||||
plugin: {
|
||||
id: "example-work",
|
||||
path: "/plugins/example",
|
||||
enabled: true,
|
||||
status: "running",
|
||||
},
|
||||
},
|
||||
}).type,
|
||||
).toBe("plugin.directory.install.response");
|
||||
});
|
||||
|
||||
it("keeps the catalog change notification safe for older clients", () => {
|
||||
expect(
|
||||
StatusMessageSchema.parse({
|
||||
type: "status",
|
||||
payload: { status: "plugin_catalog_changed", pluginId: "example" },
|
||||
}),
|
||||
).toEqual({
|
||||
type: "status",
|
||||
payload: { status: "plugin_catalog_changed", pluginId: "example" },
|
||||
});
|
||||
});
|
||||
|
||||
it("requires plugin action payloads and keeps remove empty", () => {
|
||||
expect(() =>
|
||||
SessionOutboundMessageSchema.parse({
|
||||
type: "plugin.reload.response",
|
||||
payload: { requestId: "request-1" },
|
||||
}),
|
||||
).toThrow();
|
||||
expect(
|
||||
SessionOutboundMessageSchema.parse({
|
||||
type: "plugin.remove.response",
|
||||
payload: { requestId: "request-2" },
|
||||
}),
|
||||
).toEqual({ type: "plugin.remove.response", payload: { requestId: "request-2" } });
|
||||
expect(() =>
|
||||
SessionOutboundMessageSchema.parse({
|
||||
type: "plugin.remove.response",
|
||||
payload: { requestId: "request-2", plugin: { id: "extra" } },
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -173,6 +173,21 @@ const MutableRelayConfigSchema = z
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const PluginIdSchema = z.string().regex(/^[a-z][a-z0-9-]*$/);
|
||||
|
||||
export const DirectoryPluginSourceSchema = z
|
||||
.object({
|
||||
source: z.literal("directory"),
|
||||
path: z.string().min(1),
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const PluginSourceSchema = z.discriminatedUnion("source", [DirectoryPluginSourceSchema]);
|
||||
|
||||
export type PluginSource = z.infer<typeof PluginSourceSchema>;
|
||||
|
||||
export const MutableDaemonConfigSchema = z
|
||||
.object({
|
||||
// COMPAT(relayConfig): added in v0.2.6, remove after 2027-01-31 when old daemons are unsupported.
|
||||
@@ -190,6 +205,8 @@ export const MutableDaemonConfigSchema = z
|
||||
appendSystemPrompt: z.string().default(""),
|
||||
terminalProfiles: z.array(TerminalProfileSchema).optional(),
|
||||
agentProfiles: z.array(AgentProfileSchema).optional(),
|
||||
pluginsEnabled: z.boolean().optional(),
|
||||
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -208,6 +225,8 @@ export const MutableDaemonConfigPatchSchema = z
|
||||
appendSystemPrompt: z.string().optional(),
|
||||
terminalProfiles: z.array(TerminalProfileSchema).optional(),
|
||||
agentProfiles: z.array(AgentProfileSchema).optional(),
|
||||
pluginsEnabled: z.boolean().optional(),
|
||||
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
|
||||
})
|
||||
.partial()
|
||||
.passthrough();
|
||||
@@ -1003,6 +1022,16 @@ export const ForgeIssueAttachmentSchema = z.object({
|
||||
projectPath: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ExternalResourceAttachmentMetadataSchema = z.object({
|
||||
provider: z.string(),
|
||||
providerLabel: z.string(),
|
||||
resourceType: z.string(),
|
||||
id: z.string(),
|
||||
identifier: z.string(),
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
});
|
||||
|
||||
export const TextAttachmentSchema = z
|
||||
.object({
|
||||
type: z.literal("text"),
|
||||
@@ -1010,6 +1039,7 @@ export const TextAttachmentSchema = z
|
||||
contextKind: z.string().optional(),
|
||||
title: z.string().nullable().optional(),
|
||||
text: z.string(),
|
||||
externalResource: ExternalResourceAttachmentMetadataSchema.optional(),
|
||||
})
|
||||
.transform(({ contextKind, ...attachment }) => ({
|
||||
...attachment,
|
||||
@@ -1291,6 +1321,46 @@ export const DiagnosticsRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const PluginCatalogGetRequestSchema = z.object({
|
||||
type: z.literal("plugin.catalog.get.request"),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const PluginListRequestSchema = z.object({
|
||||
type: z.literal("plugin.list.request"),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const PluginDirectoryInstallRequestSchema = z.object({
|
||||
type: z.literal("plugin.directory.install.request"),
|
||||
requestId: z.string(),
|
||||
path: z.string().min(1),
|
||||
id: PluginIdSchema.optional(),
|
||||
});
|
||||
|
||||
export const PluginDirectoryInspectRequestSchema = z.object({
|
||||
type: z.literal("plugin.directory.inspect.request"),
|
||||
requestId: z.string(),
|
||||
path: z.string().min(1),
|
||||
});
|
||||
|
||||
function pluginIdRequest<const Type extends string>(type: Type) {
|
||||
return z.object({ type: z.literal(type), requestId: z.string(), pluginId: PluginIdSchema });
|
||||
}
|
||||
|
||||
export const PluginReloadRequestSchema = pluginIdRequest("plugin.reload.request");
|
||||
export const PluginEnableRequestSchema = pluginIdRequest("plugin.enable.request");
|
||||
export const PluginDisableRequestSchema = pluginIdRequest("plugin.disable.request");
|
||||
export const PluginRemoveRequestSchema = pluginIdRequest("plugin.remove.request");
|
||||
|
||||
export const PluginRpcInvokeRequestSchema = z.object({
|
||||
type: z.literal("plugin.rpc.invoke.request"),
|
||||
requestId: z.string(),
|
||||
pluginId: PluginIdSchema,
|
||||
method: z.string().min(1),
|
||||
input: z.unknown(),
|
||||
});
|
||||
|
||||
export const GetDaemonConfigRequestMessageSchema = z.object({
|
||||
type: z.literal("get_daemon_config_request"),
|
||||
requestId: z.string(),
|
||||
@@ -2760,6 +2830,15 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
HubManagementDaemonGetStatusRequestSchema,
|
||||
HubManagementDaemonDisconnectRequestSchema,
|
||||
DiagnosticsRequestSchema,
|
||||
PluginCatalogGetRequestSchema,
|
||||
PluginListRequestSchema,
|
||||
PluginDirectoryInstallRequestSchema,
|
||||
PluginDirectoryInspectRequestSchema,
|
||||
PluginReloadRequestSchema,
|
||||
PluginEnableRequestSchema,
|
||||
PluginDisableRequestSchema,
|
||||
PluginRemoveRequestSchema,
|
||||
PluginRpcInvokeRequestSchema,
|
||||
GetDaemonConfigRequestMessageSchema,
|
||||
SetDaemonConfigRequestMessageSchema,
|
||||
ReadProjectConfigRequestMessageSchema,
|
||||
@@ -3082,6 +3161,10 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
relayConfig: z.boolean().optional(),
|
||||
// COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10.
|
||||
pushTokenRevocation: z.boolean().optional(),
|
||||
// COMPAT(plugins): added in v0.3.0, remove gate after 2027-08-07.
|
||||
plugins: z.boolean().optional(),
|
||||
// COMPAT(pluginManagement): added in v0.4.0, remove gate after 2027-08-14.
|
||||
pluginManagement: z.boolean().optional(),
|
||||
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
||||
"terminal-restore-modes": z.boolean().optional(),
|
||||
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.
|
||||
@@ -3268,6 +3351,11 @@ export const DaemonConfigChangedStatusPayloadSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const PluginCatalogChangedStatusPayloadSchema = z.object({
|
||||
status: z.literal("plugin_catalog_changed"),
|
||||
pluginId: PluginIdSchema,
|
||||
});
|
||||
|
||||
export const KnownStatusPayloadSchema = z.discriminatedUnion("status", [
|
||||
AgentCreatedStatusPayloadSchema,
|
||||
AgentCreateFailedStatusPayloadSchema,
|
||||
@@ -3276,6 +3364,7 @@ export const KnownStatusPayloadSchema = z.discriminatedUnion("status", [
|
||||
ShutdownRequestedStatusPayloadSchema,
|
||||
RestartRequestedStatusPayloadSchema,
|
||||
DaemonConfigChangedStatusPayloadSchema,
|
||||
PluginCatalogChangedStatusPayloadSchema,
|
||||
]);
|
||||
|
||||
export type KnownStatusPayload = z.infer<typeof KnownStatusPayloadSchema>;
|
||||
@@ -5707,6 +5796,69 @@ export function parseHubExecutionOutboundMessage(value: unknown): HubExecutionOu
|
||||
|
||||
export type DaemonUpdateProgressMessage = z.infer<typeof DaemonUpdateProgressMessageSchema>;
|
||||
|
||||
export const PluginCatalogGetResponseSchema = z.object({
|
||||
type: z.literal("plugin.catalog.get.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
plugins: z.array(
|
||||
z.object({
|
||||
id: PluginIdSchema,
|
||||
clientBundle: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
export const PluginStatusSchema = z.enum(["running", "disabled", "failed"]);
|
||||
export type PluginStatus = z.infer<typeof PluginStatusSchema>;
|
||||
|
||||
export const PluginListItemSchema = z.object({
|
||||
id: PluginIdSchema,
|
||||
path: z.string(),
|
||||
enabled: z.boolean(),
|
||||
status: PluginStatusSchema,
|
||||
error: z.string().optional(),
|
||||
});
|
||||
export type PluginListItem = z.infer<typeof PluginListItemSchema>;
|
||||
|
||||
export const PluginListResponseSchema = z.object({
|
||||
type: z.literal("plugin.list.response"),
|
||||
payload: z.object({ requestId: z.string(), plugins: z.array(PluginListItemSchema) }),
|
||||
});
|
||||
|
||||
export const PluginDirectoryInstallResponseSchema = z.object({
|
||||
type: z.literal("plugin.directory.install.response"),
|
||||
payload: z.object({ requestId: z.string(), plugin: PluginListItemSchema }),
|
||||
});
|
||||
|
||||
export const PluginDirectoryInspectResponseSchema = z.object({
|
||||
type: z.literal("plugin.directory.inspect.response"),
|
||||
payload: z.object({ requestId: z.string(), id: PluginIdSchema }),
|
||||
});
|
||||
|
||||
function pluginActionResponse<const Type extends string>(type: Type) {
|
||||
return z.object({
|
||||
type: z.literal(type),
|
||||
payload: z.object({ requestId: z.string(), plugin: PluginListItemSchema }),
|
||||
});
|
||||
}
|
||||
|
||||
export const PluginReloadResponseSchema = pluginActionResponse("plugin.reload.response");
|
||||
export const PluginEnableResponseSchema = pluginActionResponse("plugin.enable.response");
|
||||
export const PluginDisableResponseSchema = pluginActionResponse("plugin.disable.response");
|
||||
export const PluginRemoveResponseSchema = z.object({
|
||||
type: z.literal("plugin.remove.response"),
|
||||
payload: z.object({ requestId: z.string() }).strict(),
|
||||
});
|
||||
|
||||
export const PluginRpcInvokeResponseSchema = z.object({
|
||||
type: z.literal("plugin.rpc.invoke.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
output: z.unknown(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
HubExecutionAgentCreateResponseSchema,
|
||||
HubExecutionAgentValidateResponseSchema,
|
||||
@@ -5714,6 +5866,15 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
HubExecutionAgentUpdateSchema,
|
||||
HubExecutionAgentStreamSchema,
|
||||
BrowserAutomationExecuteRequestSchema,
|
||||
PluginCatalogGetResponseSchema,
|
||||
PluginListResponseSchema,
|
||||
PluginDirectoryInstallResponseSchema,
|
||||
PluginDirectoryInspectResponseSchema,
|
||||
PluginReloadResponseSchema,
|
||||
PluginEnableResponseSchema,
|
||||
PluginDisableResponseSchema,
|
||||
PluginRemoveResponseSchema,
|
||||
PluginRpcInvokeResponseSchema,
|
||||
ActivityLogMessageSchema,
|
||||
AssistantChunkMessageSchema,
|
||||
AudioOutputMessageSchema,
|
||||
|
||||
Reference in New Issue
Block a user