/** * Worker-side RPC host — runs inside the child process spawned by the host. * * This module is the worker-side counterpart to the server's * `PluginWorkerManager`. It: * * 1. Reads newline-delimited JSON-RPC 2.0 requests from **stdin** * 2. Dispatches them to the appropriate plugin handler (events, jobs, tools, …) * 3. Writes JSON-RPC 2.0 responses back on **stdout** * 4. Provides a concrete `PluginContext` whose SDK client methods (e.g. * `ctx.state.get()`, `ctx.events.emit()`) send JSON-RPC requests to the * host on stdout and await responses on stdin. * * ## Message flow * * ``` * Host (parent) Worker (this module) * | | * |--- request(initialize) -------------> | → calls plugin.setup(ctx) * |<-- response(ok:true) ---------------- | * | | * |--- notification(onEvent) -----------> | → dispatches to registered handler * | | * |<-- request(state.get) --------------- | ← SDK client call from plugin code * |--- response(result) ----------------> | * | | * |--- request(shutdown) ---------------> | → calls plugin.onShutdown() * |<-- response(void) ------------------ | * | (process exits) * ``` * * @see PLUGIN_SPEC.md §12 — Process Model * @see PLUGIN_SPEC.md §13 — Host-Worker Protocol * @see PLUGIN_SPEC.md §14 — SDK Surface */ import fs from "node:fs"; import { AsyncLocalStorage } from "node:async_hooks"; import path from "node:path"; import { createInterface, type Interface as ReadlineInterface } from "node:readline"; import { fileURLToPath } from "node:url"; import type { AskUserQuestionsInteraction, PaperclipPluginManifestV1, RequestCheckboxConfirmationInteraction, RequestConfirmationInteraction, SuggestTasksInteraction, } from "@paperclipai/shared"; import type { PaperclipPlugin } from "./define-plugin.js"; import type { PluginApiRequestInput, PluginHealthDiagnostics, PluginConfigValidationResult, PluginWebhookInput, } from "./define-plugin.js"; import type { PluginContext, PluginEvent, PluginJobContext, PluginLauncherRegistration, ScopeKey, ToolRunContext, ToolResult, EventFilter, AgentSessionEvent, } from "./types.js"; import type { JsonRpcId, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, InitializeParams, InitializeResult, ConfigChangedParams, ValidateConfigParams, OnEventParams, RunJobParams, GetDataParams, PerformActionParams, PluginPerformActionActorContext, PluginPerformActionContext, ExecuteToolParams, DetectExternalObjectsParams, ResolveExternalObjectParams, RefreshExternalObjectsParams, PluginEnvironmentAcquireLeaseParams, PluginEnvironmentDestroyLeaseParams, PluginEnvironmentExecuteParams, PluginEnvironmentRunnerIngressEndpointParams, PluginEnvironmentSyncInParams, PluginEnvironmentSyncOutParams, PluginEnvironmentRealizeWorkspaceParams, PluginEnvironmentReleaseLeaseParams, PluginEnvironmentResumeLeaseParams, PluginEnvironmentValidateConfigParams, PluginEnvironmentProbeParams, PluginEnvironmentStartInteractiveSetupParams, PluginEnvironmentGetInteractiveSetupParams, PluginEnvironmentCaptureTemplateParams, PluginEnvironmentCancelInteractiveSetupParams, PluginEnvironmentDeleteTemplateParams, PluginLoginPtyOpenParams, PluginLoginPtyInputParams, PluginLoginPtyStopParams, PluginLoginPtyCloseParams, PluginDuplexChannelOpenParams, PluginDuplexChannelWriteParams, PluginDuplexChannelStopParams, PluginDuplexChannelCloseParams, PluginInvocationContext, WorkerToHostMethodName, WorkerToHostMethods, } from "./protocol.js"; import { LOGIN_PTY_OUTPUT_NOTIFICATION, LOGIN_PTY_EXIT_NOTIFICATION, DUPLEX_CHANNEL_DATA_NOTIFICATION, DUPLEX_CHANNEL_EXIT_NOTIFICATION, JSONRPC_VERSION, JSONRPC_ERROR_CODES, PLUGIN_RPC_ERROR_CODES, createRequest, createSuccessResponse, createErrorResponse, createNotification, parseMessage, serializeMessage, isJsonRpcRequest, isJsonRpcResponse, isJsonRpcNotification, isJsonRpcSuccessResponse, isJsonRpcErrorResponse, JsonRpcParseError, JsonRpcCallError, encodeChannelBytes, } from "./protocol.js"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /** * Options for starting the worker-side RPC host. */ export interface WorkerRpcHostOptions { /** * The plugin definition returned by `definePlugin()`. * * The worker entrypoint should import its plugin and pass it here. */ plugin: PaperclipPlugin; /** * Input stream to read JSON-RPC messages from. * Defaults to `process.stdin`. */ stdin?: NodeJS.ReadableStream; /** * Output stream to write JSON-RPC messages to. * Defaults to `process.stdout`. */ stdout?: NodeJS.WritableStream; /** * Default timeout (ms) for worker→host RPC calls. * Defaults to 30 000 ms. */ rpcTimeoutMs?: number; } /** * A running worker RPC host instance. * * Returned by `startWorkerRpcHost()`. Callers (usually just the worker * bootstrap) hold a reference so they can inspect status or force-stop. */ export interface WorkerRpcHost { /** Whether the host is currently running and listening for messages. */ readonly running: boolean; /** * Stop the RPC host immediately. Closes readline, rejects pending * outbound calls, and does NOT call the plugin's shutdown hook (that * should have already been called via the `shutdown` RPC method). */ stop(): void; } // --------------------------------------------------------------------------- // Internal: event registration // --------------------------------------------------------------------------- interface EventRegistration { name: string; filter?: EventFilter; fn: (event: PluginEvent) => Promise; } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** Default timeout for worker→host RPC calls. */ const DEFAULT_RPC_TIMEOUT_MS = 30_000; function realpathOrResolvedPath(filePath: string): string { const resolvedPath = path.resolve(filePath); try { return fs.realpathSync.native(resolvedPath); } catch { return resolvedPath; } } /** * Order-independent structural equality for two plugin config objects. * * Config arrives as parsed JSON, so plain `JSON.stringify` comparison is * sensitive to key ordering across independent saves. Canonicalizing with * recursively sorted object keys makes an idempotent replay of the same config * compare equal regardless of serialization order. */ function configsEqual(a: unknown, b: unknown): boolean { return canonicalize(a) === canonicalize(b); } function canonicalize(value: unknown): string { if (value === null || typeof value !== "object") { return JSON.stringify(value) ?? "null"; } if (Array.isArray(value)) { return `[${value.map(canonicalize).join(",")}]`; } const entries = Object.entries(value as Record) .filter(([, v]) => v !== undefined) .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) .map(([key, v]) => `${JSON.stringify(key)}:${canonicalize(v)}`); return `{${entries.join(",")}}`; } export function isWorkerEntrypoint(entry: string, moduleUrl: string): boolean { const thisFile = realpathOrResolvedPath(fileURLToPath(moduleUrl)); const entryPath = realpathOrResolvedPath(entry); return thisFile === entryPath; } // --------------------------------------------------------------------------- // startWorkerRpcHost // --------------------------------------------------------------------------- /** * Options for runWorker when testing (optional stdio to avoid using process streams). * When both stdin and stdout are provided, the "is main module" check is skipped * and the host is started with these streams. Used by tests. */ export interface RunWorkerOptions { stdin?: NodeJS.ReadableStream; stdout?: NodeJS.WritableStream; } /** * Start the worker when this module is the process entrypoint. * * Call this at the bottom of your worker file so that when the host runs * `node dist/worker.js`, the RPC host starts and the process stays alive. * When the module is imported (e.g. for re-exports or tests), nothing runs. * * When `options.stdin` and `options.stdout` are provided (e.g. in tests), * the main-module check is skipped and the host is started with those streams. * * @example * ```ts * const plugin = definePlugin({ ... }); * export default plugin; * runWorker(plugin, import.meta.url); * ``` */ export function runWorker( plugin: PaperclipPlugin, moduleUrl: string, options?: RunWorkerOptions, ): WorkerRpcHost | void { if ( options?.stdin != null && options?.stdout != null ) { return startWorkerRpcHost({ plugin, stdin: options.stdin, stdout: options.stdout, }); } const entry = process.argv[1]; if (typeof entry !== "string") return; if (isWorkerEntrypoint(entry, moduleUrl)) { startWorkerRpcHost({ plugin }); } } /** * Start the worker-side RPC host. * * This function is typically called from a thin bootstrap script that is the * actual entrypoint of the child process: * * ```ts * // worker-bootstrap.ts * import plugin from "./worker.js"; * import { startWorkerRpcHost } from "@paperclipai/plugin-sdk"; * * startWorkerRpcHost({ plugin }); * ``` * * The host begins listening on stdin immediately. It does NOT call * `plugin.definition.setup()` yet — that happens when the host sends the * `initialize` RPC. * * @returns A handle for inspecting or stopping the RPC host */ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost { const { plugin } = options; const stdinStream = options.stdin ?? process.stdin; const stdoutStream = options.stdout ?? process.stdout; const rpcTimeoutMs = options.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS; // ----------------------------------------------------------------------- // State // ----------------------------------------------------------------------- let running = true; let initialized = false; let manifest: PaperclipPluginManifestV1 | null = null; let currentConfig: Record = {}; // The company whose config was last applied via configChanged. Used to fail // closed when a single-tenant plugin would be collapsed onto a second, // distinct company's config. `null` until the first company-scoped delivery. let configCompanyId: string | null = null; let databaseNamespace: string | null = null; const invocationContextStorage = new AsyncLocalStorage(); // Plugin handler registrations (populated during setup()) const eventHandlers: EventRegistration[] = []; const jobHandlers = new Map Promise>(); const launcherRegistrations = new Map(); const dataHandlers = new Map) => Promise>(); const actionHandlers = new Map< string, (params: Record, context: PluginPerformActionContext) => Promise >(); const toolHandlers = new Map; fn: (params: unknown, runCtx: ToolRunContext) => Promise; }>(); // Agent session event callbacks (populated by sendMessage, cleared by close) const sessionEventCallbacks = new Map void>(); // Pending outbound (worker→host) requests const pendingRequests = new Map void; timer: ReturnType; }>(); let nextOutboundId = 1; const MAX_OUTBOUND_ID = Number.MAX_SAFE_INTEGER - 1; // ----------------------------------------------------------------------- // Outbound messaging (worker → host) // ----------------------------------------------------------------------- function sendMessage(message: unknown): void { if (!running) return; const serialized = serializeMessage(message as any); stdoutStream.write(serialized); } /** * Send a typed JSON-RPC request to the host and await the response. */ function callHost( method: M, params: WorkerToHostMethods[M][0], timeoutMs?: number, ): Promise { return new Promise((resolve, reject) => { if (!running) { reject(new Error(`Cannot call "${method}" — worker RPC host is not running`)); return; } if (nextOutboundId >= MAX_OUTBOUND_ID) { nextOutboundId = 1; } const id = nextOutboundId++; const timeout = timeoutMs ?? rpcTimeoutMs; let settled = false; const settle = (fn: (value: T) => void, value: T): void => { if (settled) return; settled = true; clearTimeout(timer); pendingRequests.delete(id); fn(value); }; const timer = setTimeout(() => { settle( reject, new JsonRpcCallError({ code: PLUGIN_RPC_ERROR_CODES.TIMEOUT, message: `Worker→host call "${method}" timed out after ${timeout}ms`, }), ); }, timeout); pendingRequests.set(id, { resolve: (response: JsonRpcResponse) => { if (isJsonRpcSuccessResponse(response)) { settle(resolve, response.result as WorkerToHostMethods[M][1]); } else if (isJsonRpcErrorResponse(response)) { settle(reject, new JsonRpcCallError(response.error)); } else { settle(reject, new Error(`Unexpected response format for "${method}"`)); } }, timer, }); try { const activeInvocation = invocationContextStorage.getStore(); const request = { ...createRequest(method, params, id), ...(activeInvocation ? { paperclipInvocationId: activeInvocation.id } : {}), }; sendMessage(request); } catch (err) { settle(reject, err instanceof Error ? err : new Error(String(err))); } }); } /** * Send a JSON-RPC notification to the host (fire-and-forget). */ function notifyHost(method: string, params: unknown): void { try { const activeInvocation = invocationContextStorage.getStore(); sendMessage({ ...createNotification(method, params), ...(activeInvocation ? { paperclipInvocationId: activeInvocation.id } : {}), }); } catch { // Swallow — the host may have closed stdin } } // ----------------------------------------------------------------------- // Build the PluginContext (SDK surface for plugin code) // ----------------------------------------------------------------------- function buildContext(): PluginContext { return { get manifest() { if (!manifest) throw new Error("Plugin context accessed before initialization"); return manifest; }, config: { async get(companyId?: string) { return callHost("config.get", companyId ? { companyId } : {}); }, }, localFolders: { declarations() { if (!manifest) throw new Error("Plugin context accessed before initialization"); return manifest.localFolders ?? []; }, async configure(input) { return callHost("localFolders.configure", { companyId: input.companyId, folderKey: input.folderKey, path: input.path, access: input.access, requiredDirectories: input.requiredDirectories, requiredFiles: input.requiredFiles, }); }, async status(companyId: string, folderKey: string) { return callHost("localFolders.status", { companyId, folderKey }); }, async list(companyId: string, folderKey: string, options = {}) { return callHost("localFolders.list", { companyId, folderKey, relativePath: options.relativePath, recursive: options.recursive, maxEntries: options.maxEntries, }); }, async readText(companyId: string, folderKey: string, relativePath: string) { return callHost("localFolders.readText", { companyId, folderKey, relativePath }); }, async writeTextAtomic(companyId: string, folderKey: string, relativePath: string, contents: string) { return callHost("localFolders.writeTextAtomic", { companyId, folderKey, relativePath, contents, }); }, async deleteFile(companyId: string, folderKey: string, relativePath: string) { return callHost("localFolders.deleteFile", { companyId, folderKey, relativePath }); }, }, events: { on( name: string, filterOrFn: EventFilter | ((event: PluginEvent) => Promise), maybeFn?: (event: PluginEvent) => Promise, ): () => void { let registration: EventRegistration; if (typeof filterOrFn === "function") { registration = { name, fn: filterOrFn }; } else { if (!maybeFn) throw new Error("Event handler function is required"); registration = { name, filter: filterOrFn, fn: maybeFn }; } eventHandlers.push(registration); // Register subscription on the host so events are forwarded to this worker void callHost("events.subscribe", { eventPattern: name, filter: registration.filter ?? null }).catch((err) => { notifyHost("log", { level: "warn", message: `Failed to subscribe to event "${name}" on host: ${err instanceof Error ? err.message : String(err)}`, }); }); return () => { const idx = eventHandlers.indexOf(registration); if (idx !== -1) eventHandlers.splice(idx, 1); }; }, async emit(name: string, companyId: string, payload: unknown): Promise { await callHost("events.emit", { name, companyId, payload }); }, }, jobs: { register(key: string, fn: (job: PluginJobContext) => Promise): void { jobHandlers.set(key, fn); }, }, launchers: { register(launcher: PluginLauncherRegistration): void { launcherRegistrations.set(launcher.id, launcher); }, }, db: { get namespace() { return databaseNamespace ?? ""; }, async query>(sql: string, params?: unknown[]): Promise { return callHost("db.query", { sql, params }) as Promise; }, async execute(sql: string, params?: unknown[]) { return callHost("db.execute", { sql, params }); }, }, http: { async fetch(url: string, init?: RequestInit): Promise { const serializedInit: Record = {}; if (init) { if (init.method) serializedInit.method = init.method; if (init.headers) { // Normalize headers to a plain object if (init.headers instanceof Headers) { const obj: Record = {}; init.headers.forEach((v, k) => { obj[k] = v; }); serializedInit.headers = obj; } else if (Array.isArray(init.headers)) { const obj: Record = {}; for (const [k, v] of init.headers) obj[k] = v; serializedInit.headers = obj; } else { serializedInit.headers = init.headers; } } if (init.body !== undefined && init.body !== null) { serializedInit.body = typeof init.body === "string" ? init.body : String(init.body); } } const result = await callHost("http.fetch", { url, init: Object.keys(serializedInit).length > 0 ? serializedInit : undefined, }); // Reconstruct a Response-like object from the serialized result return new Response(result.body, { status: result.status, statusText: result.statusText, headers: result.headers, }); }, }, secrets: { async resolve(secretRef, options = {}): Promise { return callHost("secrets.resolve", { secretRef, companyId: options.companyId, configPath: options.configPath, }); }, }, activity: { async log(entry): Promise { await callHost("activity.log", { companyId: entry.companyId, message: entry.message, entityType: entry.entityType, entityId: entry.entityId, metadata: entry.metadata, }); }, }, state: { async get(input: ScopeKey): Promise { return callHost("state.get", { scopeKind: input.scopeKind, scopeId: input.scopeId, namespace: input.namespace, stateKey: input.stateKey, }); }, async set(input: ScopeKey, value: unknown): Promise { await callHost("state.set", { scopeKind: input.scopeKind, scopeId: input.scopeId, namespace: input.namespace, stateKey: input.stateKey, value, }); }, async delete(input: ScopeKey): Promise { await callHost("state.delete", { scopeKind: input.scopeKind, scopeId: input.scopeId, namespace: input.namespace, stateKey: input.stateKey, }); }, }, entities: { async upsert(input) { return callHost("entities.upsert", { entityType: input.entityType, scopeKind: input.scopeKind, scopeId: input.scopeId, externalId: input.externalId, title: input.title, status: input.status, data: input.data, }); }, async list(query) { return callHost("entities.list", { entityType: query.entityType, scopeKind: query.scopeKind, scopeId: query.scopeId, externalId: query.externalId, limit: query.limit, offset: query.offset, }); }, }, projects: { async list(input) { return callHost("projects.list", { companyId: input.companyId, limit: input.limit, offset: input.offset, }); }, async get(projectId: string, companyId: string) { return callHost("projects.get", { projectId, companyId }); }, async listWorkspaces(projectId: string, companyId: string) { return callHost("projects.listWorkspaces", { projectId, companyId }); }, async getPrimaryWorkspace(projectId: string, companyId: string) { return callHost("projects.getPrimaryWorkspace", { projectId, companyId }); }, async getWorkspaceForIssue(issueId: string, companyId: string) { return callHost("projects.getWorkspaceForIssue", { issueId, companyId }); }, managed: { async get(projectKey: string, companyId: string) { return callHost("projects.managed.get", { projectKey, companyId }); }, async reconcile(projectKey: string, companyId: string) { return callHost("projects.managed.reconcile", { projectKey, companyId }); }, async reset(projectKey: string, companyId: string) { return callHost("projects.managed.reset", { projectKey, companyId }); }, }, }, executionWorkspaces: { async get(workspaceId: string, companyId: string) { return callHost("executionWorkspaces.get", { workspaceId, companyId }); }, }, routines: { managed: { async get(routineKey: string, companyId: string) { return callHost("routines.managed.get", { routineKey, companyId }); }, async reconcile( routineKey: string, companyId: string, overrides?: { assigneeAgentId?: string | null; projectId?: string | null }, ) { return callHost("routines.managed.reconcile", { routineKey, companyId, ...overrides }); }, async reset( routineKey: string, companyId: string, overrides?: { assigneeAgentId?: string | null; projectId?: string | null }, ) { return callHost("routines.managed.reset", { routineKey, companyId, ...overrides }); }, async update(routineKey: string, companyId: string, patch: { status?: string }) { return callHost("routines.managed.update", { routineKey, companyId, ...patch }); }, async run( routineKey: string, companyId: string, overrides?: { assigneeAgentId?: string | null; projectId?: string | null }, ) { return callHost("routines.managed.run", { routineKey, companyId, ...overrides }); }, }, }, skills: { managed: { async get(skillKey: string, companyId: string) { return callHost("skills.managed.get", { skillKey, companyId }); }, async reconcile(skillKey: string, companyId: string) { return callHost("skills.managed.reconcile", { skillKey, companyId }); }, async reset(skillKey: string, companyId: string) { return callHost("skills.managed.reset", { skillKey, companyId }); }, }, }, companies: { async list(input) { return callHost("companies.list", { limit: input?.limit, offset: input?.offset, }); }, async get(companyId: string) { return callHost("companies.get", { companyId }); }, }, issues: { async list(input) { return callHost("issues.list", { companyId: input.companyId, projectId: input.projectId, assigneeAgentId: input.assigneeAgentId, originKind: input.originKind, originKindPrefix: input.originKindPrefix, originId: input.originId, status: input.status, includePluginOperations: input.includePluginOperations, limit: input.limit, offset: input.offset, }); }, async get(issueId: string, companyId: string) { return callHost("issues.get", { issueId, companyId }); }, async create(input) { return callHost("issues.create", { companyId: input.companyId, projectId: input.projectId, goalId: input.goalId, parentId: input.parentId, inheritExecutionWorkspaceFromIssueId: input.inheritExecutionWorkspaceFromIssueId, title: input.title, description: input.description, status: input.status, priority: input.priority, assigneeAgentId: input.assigneeAgentId, assigneeUserId: input.assigneeUserId, requestDepth: input.requestDepth, billingCode: input.billingCode, assigneeAdapterOverrides: input.assigneeAdapterOverrides, surfaceVisibility: input.surfaceVisibility, originKind: input.originKind, originId: input.originId, originRunId: input.originRunId, blockedByIssueIds: input.blockedByIssueIds, labelIds: input.labelIds, executionWorkspaceId: input.executionWorkspaceId, executionWorkspacePreference: input.executionWorkspacePreference, executionWorkspaceSettings: input.executionWorkspaceSettings, actorAgentId: input.actor?.actorAgentId, actorUserId: input.actor?.actorUserId, actorRunId: input.actor?.actorRunId, }); }, async update(issueId: string, patch, companyId: string, actor) { return callHost("issues.update", { issueId, patch: { ...(patch as Record), actorAgentId: actor?.actorAgentId, actorUserId: actor?.actorUserId, actorRunId: actor?.actorRunId, }, companyId, }); }, async assertCheckoutOwner(input) { return callHost("issues.assertCheckoutOwner", input); }, async getSubtree(issueId: string, companyId: string, options) { return callHost("issues.getSubtree", { issueId, companyId, includeRoot: options?.includeRoot, includeRelations: options?.includeRelations, includeDocuments: options?.includeDocuments, includeActiveRuns: options?.includeActiveRuns, includeAssignees: options?.includeAssignees, }); }, async requestWakeup(issueId: string, companyId: string, options) { return callHost("issues.requestWakeup", { issueId, companyId, reason: options?.reason, contextSource: options?.contextSource, idempotencyKey: options?.idempotencyKey, actorAgentId: options?.actorAgentId, actorUserId: options?.actorUserId, actorRunId: options?.actorRunId, }); }, async requestWakeups(issueIds: string[], companyId: string, options) { return callHost("issues.requestWakeups", { issueIds, companyId, reason: options?.reason, contextSource: options?.contextSource, idempotencyKeyPrefix: options?.idempotencyKeyPrefix, actorAgentId: options?.actorAgentId, actorUserId: options?.actorUserId, actorRunId: options?.actorRunId, }); }, async listComments(issueId: string, companyId: string) { return callHost("issues.listComments", { issueId, companyId }); }, async createComment( issueId: string, body: string, companyId: string, options?: { authorAgentId?: string; actorUserId?: string }, ) { return callHost("issues.createComment", { issueId, body, companyId, authorAgentId: options?.authorAgentId, actorUserId: options?.actorUserId, }); }, async createInteraction(issueId: string, interaction, companyId: string, options?: { authorAgentId?: string }) { return callHost("issues.createInteraction", { issueId, companyId, interaction, authorAgentId: options?.authorAgentId, }); }, async suggestTasks( issueId: string, interaction, companyId: string, options?: { authorAgentId?: string }, ): Promise { return callHost("issues.createInteraction", { issueId, companyId, interaction: { ...interaction, kind: "suggest_tasks", }, authorAgentId: options?.authorAgentId, }) as Promise; }, async askUserQuestions( issueId: string, interaction, companyId: string, options?: { authorAgentId?: string }, ): Promise { return callHost("issues.createInteraction", { issueId, companyId, interaction: { ...interaction, kind: "ask_user_questions", }, authorAgentId: options?.authorAgentId, }) as Promise; }, async requestConfirmation( issueId: string, interaction, companyId: string, options?: { authorAgentId?: string }, ): Promise { return callHost("issues.createInteraction", { issueId, companyId, interaction: { ...interaction, kind: "request_confirmation", }, authorAgentId: options?.authorAgentId, }) as Promise; }, async requestCheckboxConfirmation( issueId: string, interaction, companyId: string, options?: { authorAgentId?: string }, ): Promise { return callHost("issues.createInteraction", { issueId, companyId, interaction: { ...interaction, kind: "request_checkbox_confirmation", }, authorAgentId: options?.authorAgentId, }) as Promise; }, async listInteractions(issueId: string, companyId: string) { return callHost("issues.listInteractions", { issueId, companyId }); }, async respondInteraction( issueId: string, interactionId: string, input: { action: "accept" | "reject"; actorUserId?: string; reason?: string | null }, companyId: string, ) { return callHost("issues.respondInteraction", { issueId, interactionId, companyId, action: input.action, actorUserId: input.actorUserId, reason: input.reason, }); }, async listAttachments(issueId: string, companyId: string) { return callHost("issues.listAttachments", { issueId, companyId }); }, async getAttachmentContent( attachmentId: string, companyId: string, options?: { maxBytes?: number | null }, ) { return callHost("issues.getAttachmentContent", { attachmentId, companyId, maxBytes: options?.maxBytes ?? null, }); }, documents: { async list(issueId: string, companyId: string) { return callHost("issues.documents.list", { issueId, companyId }); }, async get(issueId: string, key: string, companyId: string) { return callHost("issues.documents.get", { issueId, key, companyId }); }, async upsert(input) { return callHost("issues.documents.upsert", { issueId: input.issueId, key: input.key, body: input.body, companyId: input.companyId, title: input.title, format: input.format, changeSummary: input.changeSummary, }); }, async delete(issueId: string, key: string, companyId: string) { return callHost("issues.documents.delete", { issueId, key, companyId }); }, }, relations: { async get(issueId: string, companyId: string) { return callHost("issues.relations.get", { issueId, companyId }); }, async setBlockedBy(issueId: string, blockedByIssueIds: string[], companyId: string, actor) { return callHost("issues.relations.setBlockedBy", { issueId, companyId, blockedByIssueIds, actorAgentId: actor?.actorAgentId, actorUserId: actor?.actorUserId, actorRunId: actor?.actorRunId, }); }, async addBlockers(issueId: string, blockerIssueIds: string[], companyId: string, actor) { return callHost("issues.relations.addBlockers", { issueId, companyId, blockerIssueIds, actorAgentId: actor?.actorAgentId, actorUserId: actor?.actorUserId, actorRunId: actor?.actorRunId, }); }, async removeBlockers(issueId: string, blockerIssueIds: string[], companyId: string, actor) { return callHost("issues.relations.removeBlockers", { issueId, companyId, blockerIssueIds, actorAgentId: actor?.actorAgentId, actorUserId: actor?.actorUserId, actorRunId: actor?.actorRunId, }); }, }, summaries: { async getOrchestration(input) { return callHost("issues.summaries.getOrchestration", input); }, }, }, approvals: { async list(input: { companyId: string; status?: string | null }) { return callHost("approvals.list", { companyId: input.companyId, status: input.status, }); }, async get(approvalId: string, companyId: string) { return callHost("approvals.get", { approvalId, companyId }); }, async decide( approvalId: string, input: { action: "approve" | "reject"; actorUserId?: string; decisionNote?: string | null }, companyId: string, ) { return callHost("approvals.decide", { approvalId, companyId, action: input.action, actorUserId: input.actorUserId, decisionNote: input.decisionNote, }); }, }, agents: { async list(input) { return callHost("agents.list", { companyId: input.companyId, status: input.status, limit: input.limit, offset: input.offset, }); }, async get(agentId: string, companyId: string) { return callHost("agents.get", { agentId, companyId }); }, async pause(agentId: string, companyId: string) { return callHost("agents.pause", { agentId, companyId }); }, async resume(agentId: string, companyId: string) { return callHost("agents.resume", { agentId, companyId }); }, async invoke(agentId: string, companyId: string, opts: { prompt: string; reason?: string }) { return callHost("agents.invoke", { agentId, companyId, prompt: opts.prompt, reason: opts.reason }); }, managed: { async get(agentKey: string, companyId: string) { return callHost("agents.managed.get", { agentKey, companyId }); }, async reconcile(agentKey: string, companyId: string) { return callHost("agents.managed.reconcile", { agentKey, companyId }); }, async reset(agentKey: string, companyId: string) { return callHost("agents.managed.reset", { agentKey, companyId }); }, }, sessions: { async create(agentId: string, companyId: string, opts?: { taskKey?: string; reason?: string }) { return callHost("agents.sessions.create", { agentId, companyId, taskKey: opts?.taskKey, reason: opts?.reason, }); }, async list(agentId: string, companyId: string) { return callHost("agents.sessions.list", { agentId, companyId }); }, async sendMessage(sessionId: string, companyId: string, opts: { prompt: string; reason?: string; onEvent?: (event: AgentSessionEvent) => void; }) { if (opts.onEvent) { sessionEventCallbacks.set(sessionId, opts.onEvent); } try { return await callHost("agents.sessions.sendMessage", { sessionId, companyId, prompt: opts.prompt, reason: opts.reason, }); } catch (err) { sessionEventCallbacks.delete(sessionId); throw err; } }, async close(sessionId: string, companyId: string) { sessionEventCallbacks.delete(sessionId); await callHost("agents.sessions.close", { sessionId, companyId }); }, }, }, goals: { async list(input) { return callHost("goals.list", { companyId: input.companyId, level: input.level, status: input.status, limit: input.limit, offset: input.offset, }); }, async get(goalId: string, companyId: string) { return callHost("goals.get", { goalId, companyId }); }, async create(input) { return callHost("goals.create", { companyId: input.companyId, title: input.title, description: input.description, level: input.level, status: input.status, parentId: input.parentId, ownerAgentId: input.ownerAgentId, }); }, async update(goalId: string, patch, companyId: string) { return callHost("goals.update", { goalId, patch: patch as Record, companyId, }); }, }, access: { members: { async list(input) { return callHost("access.members.list", { companyId: input.companyId, includeArchived: input.includeArchived, }); }, async get(memberId: string, companyId: string) { return callHost("access.members.get", { memberId, companyId }); }, async update(memberId: string, patch, companyId: string) { return callHost("access.members.update", { memberId, patch, companyId }); }, }, invites: { async list(input) { return callHost("access.invites.list", { companyId: input.companyId, state: input.state, limit: input.limit, offset: input.offset, }); }, async create(input) { return callHost("access.invites.create", { companyId: input.companyId, allowedJoinTypes: input.allowedJoinTypes, humanRole: input.humanRole, defaultsPayload: input.defaultsPayload, agentMessage: input.agentMessage, }); }, async revoke(inviteId: string, companyId: string) { return callHost("access.invites.revoke", { inviteId, companyId }); }, }, }, authorization: { grants: { async list(input) { return callHost("authorization.grants.list", input); }, async set(input) { return callHost("authorization.grants.set", input); }, }, policies: { async summary(companyId: string) { return callHost("authorization.policies.summary", { companyId }); }, async get(input) { return callHost("authorization.policies.get", input); }, async update(input) { return callHost("authorization.policies.update", input); }, async previewAssignment(input) { return callHost("authorization.policies.previewAssignment", input); }, async explainAssignment(input) { return callHost("authorization.policies.explainAssignment", input); }, }, audit: { async search(input) { return callHost("authorization.audit.search", input); }, }, }, data: { register(key: string, handler: (params: Record) => Promise): void { dataHandlers.set(key, handler); }, }, actions: { register( key: string, handler: (params: Record, context: PluginPerformActionContext) => Promise, ): void { actionHandlers.set(key, handler); }, }, streams: (() => { // Track channel → companyId so emit/close don't require companyId const channelCompanyMap = new Map(); return { open(channel: string, companyId: string): void { channelCompanyMap.set(channel, companyId); notifyHost("streams.open", { channel, companyId }); }, emit(channel: string, event: unknown): void { const companyId = channelCompanyMap.get(channel) ?? ""; notifyHost("streams.emit", { channel, companyId, event }); }, close(channel: string): void { const companyId = channelCompanyMap.get(channel) ?? ""; channelCompanyMap.delete(channel); notifyHost("streams.close", { channel, companyId }); }, }; })(), execution: { log(stream: "stdout" | "stderr", chunk: string): void { // Emit one incremental output chunk of the active execute call. // `notifyHost` stamps the active invocation id from the invocation // context, so the host correlates the chunk to the host-owned execute // route for that call. The notification carries no company id; the // host binds the company from its own execute route. A chunk sent with // no active invocation carries no id and the host drops it. if (typeof chunk !== "string" || chunk.length === 0) return; notifyHost("execute.log", { stream, chunk }); }, }, loginPty: { output(hostRouteId: string, workerSessionId: string, chunk: string): void { // Forward one raw output chunk of a live login pseudo-terminal. The // notification echoes the host route identifier and the worker session // identifier, so the host can hold more than one concurrent login // pseudo-terminal per worker and binds the chunk to its own route // while that route is open. The host drops an unknown, a stale, or a // mismatched identifier and never logs the raw bytes. This // notification carries no invocation id, because it fires after the // open reply returns. if (typeof hostRouteId !== "string" || hostRouteId.length === 0) return; if (typeof workerSessionId !== "string" || workerSessionId.length === 0) return; if (typeof chunk !== "string" || chunk.length === 0) return; notifyHost(LOGIN_PTY_OUTPUT_NOTIFICATION, { hostRouteId, workerSessionId, chunk }); }, exit(hostRouteId: string, workerSessionId: string, exitCode: number | null): void { // Forward the child exit of a live login pseudo-terminal. The host // resolves its own route's wait promise by the host route identifier // and the bound worker session identifier while that route is open. if (typeof hostRouteId !== "string" || hostRouteId.length === 0) return; if (typeof workerSessionId !== "string" || workerSessionId.length === 0) return; notifyHost(LOGIN_PTY_EXIT_NOTIFICATION, { hostRouteId, workerSessionId, exitCode: typeof exitCode === "number" ? exitCode : null, }); }, }, duplexChannel: { data(hostRouteId: string, workerSessionId: string, chunk: Uint8Array): void { // Forward one raw data chunk of a persistent duplex channel. The // notification echoes the host route identifier and the worker session // identifier, so the host routes the chunk to the exact live pair while // the route is open. The host drops an unknown or a mismatched pair and // never logs the raw bytes. This notification carries no invocation id, // because it fires after the open reply returns. // // JSON-RPC travels as JSON text, which carries no binary type, so this // is the one point where the chunk crosses from `Uint8Array` to the // wire-safe base64 form. See `ChannelBytesWireValue` in protocol.ts. if (typeof hostRouteId !== "string" || hostRouteId.length === 0) return; if (typeof workerSessionId !== "string" || workerSessionId.length === 0) return; if (!(chunk instanceof Uint8Array) || chunk.byteLength === 0) return; notifyHost(DUPLEX_CHANNEL_DATA_NOTIFICATION, { hostRouteId, workerSessionId, chunk: encodeChannelBytes(chunk), }); }, exit( hostRouteId: string, workerSessionId: string, exitCode: number | null, transportClosed?: boolean, ): void { // Forward the child exit of a persistent duplex channel. The host // resolves the open route's wait promise by the exact live pair while // the route is open. `transportClosed` carries the exit discriminator, so // the host tells a real process exit from a reason-less transport close. if (typeof hostRouteId !== "string" || hostRouteId.length === 0) return; if (typeof workerSessionId !== "string" || workerSessionId.length === 0) return; notifyHost(DUPLEX_CHANNEL_EXIT_NOTIFICATION, { hostRouteId, workerSessionId, exitCode: typeof exitCode === "number" ? exitCode : null, transportClosed: transportClosed === true, }); }, }, tools: { register( name: string, declaration: Pick, fn: (params: unknown, runCtx: ToolRunContext) => Promise, ): void { toolHandlers.set(name, { declaration, fn }); }, }, metrics: { async write(name: string, value: number, tags?: Record): Promise { await callHost("metrics.write", { name, value, tags }); }, }, telemetry: { async track( eventName: string, dimensions?: Record, ): Promise { await callHost("telemetry.track", { eventName, dimensions }); }, }, logger: { info(message: string, meta?: Record): void { notifyHost("log", { level: "info", message, meta }); }, warn(message: string, meta?: Record): void { notifyHost("log", { level: "warn", message, meta }); }, error(message: string, meta?: Record): void { notifyHost("log", { level: "error", message, meta }); }, debug(message: string, meta?: Record): void { notifyHost("log", { level: "debug", message, meta }); }, }, tracer: { startSpan( name: string, options?: { attributes?: Record }, ) { // Read the active host trace context from the per-call invocation // channel. A `traceparent` means a host span is active, so this span // may record. No `traceparent` means tracing is off: the span is a // no-op, so a lifecycle hook can always wrap work in a span. const hasTraceContext = Boolean(invocationContextStorage.getStore()?.traceparent); const attributes: Record = { ...(options?.attributes ?? {}), }; // Capture the real start time once when the span opens. The host uses // it as the span start time, so the span shows its true native width. const startTimeMs = Date.now(); let status: { code: number; message?: string } | undefined; let ended = false; return { setAttribute(key: string, value: string | number | boolean): void { attributes[key] = value; }, setStatus(next: { code: number; message?: string }): void { status = next; }, end(): void { if (ended) return; ended = true; if (!hasTraceContext) return; // Capture the real end time once at the first end call. The host // uses the pair to record the span with its true wall-clock width. const endTimeMs = Date.now(); // Send the finished span to the host once. The host re-clamps the // name and the attributes, mints the parentage from its own // invocation record, and records the span through the real tracer. // Fire-and-forget: a span must never block or fail plugin work. void callHost("span.record", { name, attributes, ...(status ? { status } : {}), startTimeMs, endTimeMs, }).catch(() => undefined); }, }; }, }, }; } const ctx = buildContext(); // ----------------------------------------------------------------------- // Inbound message handling (host → worker) // ----------------------------------------------------------------------- /** * Handle an incoming JSON-RPC request from the host. * * Dispatches to the correct handler based on the method name. */ async function handleHostRequest(request: JsonRpcRequest): Promise { const { id, method, params } = request; try { const invoke = () => dispatchMethod(method, params); const result = request.paperclipInvocation ? await invocationContextStorage.run(request.paperclipInvocation, invoke) : await invoke(); sendMessage(createSuccessResponse(id, result ?? null)); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); // Propagate specific error codes from handler errors (e.g. // METHOD_NOT_FOUND, METHOD_NOT_IMPLEMENTED) — fall back to // WORKER_ERROR for untyped exceptions. const errorCode = typeof (err as any)?.code === "number" ? (err as any).code : PLUGIN_RPC_ERROR_CODES.WORKER_ERROR; sendMessage(createErrorResponse(id, errorCode, errorMessage)); } } /** * Dispatch a host→worker method call to the appropriate handler. */ async function dispatchMethod(method: string, params: unknown): Promise { switch (method) { case "initialize": return handleInitialize(params as InitializeParams); case "health": return handleHealth(); case "shutdown": return handleShutdown(); case "validateConfig": return handleValidateConfig(params as ValidateConfigParams); case "configChanged": return handleConfigChanged(params as ConfigChangedParams); case "onEvent": return handleOnEvent(params as OnEventParams); case "runJob": return handleRunJob(params as RunJobParams); case "handleWebhook": return handleWebhook(params as PluginWebhookInput); case "handleApiRequest": return handleApiRequest(params as PluginApiRequestInput); case "getData": return handleGetData(params as GetDataParams); case "performAction": return handlePerformAction(params as PerformActionParams); case "executeTool": return handleExecuteTool(params as ExecuteToolParams); case "detectExternalObjects": return handleDetectExternalObjects(params as DetectExternalObjectsParams); case "resolveExternalObject": return handleResolveExternalObject(params as ResolveExternalObjectParams); case "refreshExternalObjects": return handleRefreshExternalObjects(params as RefreshExternalObjectsParams); case "environmentValidateConfig": return handleEnvironmentValidateConfig(params as PluginEnvironmentValidateConfigParams); case "environmentProbe": return handleEnvironmentProbe(params as PluginEnvironmentProbeParams); case "environmentAcquireLease": return handleEnvironmentAcquireLease(params as PluginEnvironmentAcquireLeaseParams); case "environmentResumeLease": return handleEnvironmentResumeLease(params as PluginEnvironmentResumeLeaseParams); case "environmentReleaseLease": return handleEnvironmentReleaseLease(params as PluginEnvironmentReleaseLeaseParams); case "environmentDestroyLease": return handleEnvironmentDestroyLease(params as PluginEnvironmentDestroyLeaseParams); case "environmentRealizeWorkspace": return handleEnvironmentRealizeWorkspace(params as PluginEnvironmentRealizeWorkspaceParams); case "environmentExecute": return handleEnvironmentExecute(params as PluginEnvironmentExecuteParams); case "environmentRunnerIngressEndpoint": return handleEnvironmentRunnerIngressEndpoint( params as PluginEnvironmentRunnerIngressEndpointParams, ); case "environmentSyncIn": return handleEnvironmentSyncIn(params as PluginEnvironmentSyncInParams); case "environmentSyncOut": return handleEnvironmentSyncOut(params as PluginEnvironmentSyncOutParams); case "environmentStartInteractiveSetup": return handleEnvironmentStartInteractiveSetup(params as PluginEnvironmentStartInteractiveSetupParams); case "environmentGetInteractiveSetup": return handleEnvironmentGetInteractiveSetup(params as PluginEnvironmentGetInteractiveSetupParams); case "environmentCaptureTemplate": return handleEnvironmentCaptureTemplate(params as PluginEnvironmentCaptureTemplateParams); case "environmentCancelInteractiveSetup": return handleEnvironmentCancelInteractiveSetup(params as PluginEnvironmentCancelInteractiveSetupParams); case "environmentDeleteTemplate": return handleEnvironmentDeleteTemplate(params as PluginEnvironmentDeleteTemplateParams); case "loginPtyOpen": return handleLoginPtyOpen(params as PluginLoginPtyOpenParams); case "loginPtyInput": return handleLoginPtyInput(params as PluginLoginPtyInputParams); case "loginPtyStop": return handleLoginPtyStop(params as PluginLoginPtyStopParams); case "loginPtyClose": return handleLoginPtyClose(params as PluginLoginPtyCloseParams); case "duplexChannelOpen": return handleDuplexChannelOpen(params as PluginDuplexChannelOpenParams); case "duplexChannelWrite": return handleDuplexChannelWrite(params as PluginDuplexChannelWriteParams); case "duplexChannelStop": return handleDuplexChannelStop(params as PluginDuplexChannelStopParams); case "duplexChannelClose": return handleDuplexChannelClose(params as PluginDuplexChannelCloseParams); default: throw Object.assign( new Error(`Unknown method: ${method}`), { code: JSONRPC_ERROR_CODES.METHOD_NOT_FOUND }, ); } } // ----------------------------------------------------------------------- // Host→Worker method handlers // ----------------------------------------------------------------------- async function handleInitialize(params: InitializeParams): Promise { if (initialized) { throw new Error("Worker already initialized"); } manifest = params.manifest; currentConfig = params.config; databaseNamespace = params.databaseNamespace ?? null; // Call the plugin's setup function await plugin.definition.setup(ctx); initialized = true; // Report which optional methods this plugin implements const supportedMethods: string[] = []; if (plugin.definition.onValidateConfig) supportedMethods.push("validateConfig"); if (plugin.definition.onConfigChanged) supportedMethods.push("configChanged"); if (plugin.definition.onHealth) supportedMethods.push("health"); if (plugin.definition.onShutdown) supportedMethods.push("shutdown"); if (plugin.definition.onApiRequest) supportedMethods.push("handleApiRequest"); if (plugin.definition.onDetectExternalObjects) supportedMethods.push("detectExternalObjects"); if (plugin.definition.onResolveExternalObject) supportedMethods.push("resolveExternalObject"); if (plugin.definition.onRefreshExternalObjects) supportedMethods.push("refreshExternalObjects"); if (plugin.definition.onEnvironmentValidateConfig) supportedMethods.push("environmentValidateConfig"); if (plugin.definition.onEnvironmentProbe) supportedMethods.push("environmentProbe"); if (plugin.definition.onEnvironmentAcquireLease) supportedMethods.push("environmentAcquireLease"); if (plugin.definition.onEnvironmentResumeLease) supportedMethods.push("environmentResumeLease"); if (plugin.definition.onEnvironmentReleaseLease) supportedMethods.push("environmentReleaseLease"); if (plugin.definition.onEnvironmentDestroyLease) supportedMethods.push("environmentDestroyLease"); if (plugin.definition.onEnvironmentRealizeWorkspace) supportedMethods.push("environmentRealizeWorkspace"); if (plugin.definition.onEnvironmentExecute) supportedMethods.push("environmentExecute"); if (plugin.definition.onEnvironmentRunnerIngressEndpoint) { supportedMethods.push("environmentRunnerIngressEndpoint"); } if (plugin.definition.onEnvironmentSyncIn) supportedMethods.push("environmentSyncIn"); if (plugin.definition.onEnvironmentSyncOut) supportedMethods.push("environmentSyncOut"); if (plugin.definition.onEnvironmentStartInteractiveSetup) supportedMethods.push("environmentStartInteractiveSetup"); if (plugin.definition.onEnvironmentGetInteractiveSetup) supportedMethods.push("environmentGetInteractiveSetup"); if (plugin.definition.onEnvironmentCaptureTemplate) supportedMethods.push("environmentCaptureTemplate"); if (plugin.definition.onEnvironmentCancelInteractiveSetup) supportedMethods.push("environmentCancelInteractiveSetup"); if (plugin.definition.onEnvironmentDeleteTemplate) supportedMethods.push("environmentDeleteTemplate"); if (plugin.definition.onLoginPtyOpen) supportedMethods.push("loginPtyOpen"); if (plugin.definition.onLoginPtyInput) supportedMethods.push("loginPtyInput"); if (plugin.definition.onLoginPtyStop) supportedMethods.push("loginPtyStop"); if (plugin.definition.onLoginPtyClose) supportedMethods.push("loginPtyClose"); if (plugin.definition.onDuplexChannelOpen) supportedMethods.push("duplexChannelOpen"); if (plugin.definition.onDuplexChannelWrite) supportedMethods.push("duplexChannelWrite"); if (plugin.definition.onDuplexChannelStop) supportedMethods.push("duplexChannelStop"); if (plugin.definition.onDuplexChannelClose) supportedMethods.push("duplexChannelClose"); return { ok: true, supportedMethods }; } async function handleHealth(): Promise { if (plugin.definition.onHealth) { return plugin.definition.onHealth(); } // Default: report OK if the worker is alive return { status: "ok" }; } async function handleShutdown(): Promise { if (plugin.definition.onShutdown) { await plugin.definition.onShutdown(); } // Schedule cleanup after we send the response. // Use setImmediate to let the response flush before exiting. // Only call process.exit() when running with real process streams. // When custom streams are provided (tests), just clean up. setImmediate(() => { cleanup(); if (!options.stdin && !options.stdout) { process.exit(0); } }); } async function handleValidateConfig( params: ValidateConfigParams, ): Promise { if (!plugin.definition.onValidateConfig) { throw Object.assign( new Error("validateConfig is not implemented by this plugin"), { code: PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED }, ); } return plugin.definition.onValidateConfig(params.config); } async function handleConfigChanged(params: ConfigChangedParams): Promise { const incomingCompanyId = params.companyId ?? null; // Fail-closed cross-tenant guard. // // A worker is spawned once per plugin (not per company), so a proactive // plugin that keeps a single worker-global config would silently collapse // onto whichever company's config was delivered last if configChanged is // called for more than one distinct company — for example the startup // config replay fanning out every stored company's config, or two operators // saving configs for different companies. That is a cross-tenant identity / // secret confusion bug (one company's bot token applied to another's work). // // Reject the second, distinct company unless the plugin explicitly declares // it handles multiple companies in one worker (multiCompanyConfig). An // idempotent replay of the *same* config for a different company id is // harmless (single-tenant plugins commonly have duplicate scope rows that // all embed the same config), so it is allowed. if ( !plugin.definition.multiCompanyConfig && incomingCompanyId !== null && configCompanyId !== null && configCompanyId !== incomingCompanyId && !configsEqual(params.config, currentConfig) ) { throw Object.assign( new Error( `configChanged: refusing to overwrite configuration for company ` + `"${configCompanyId}" with a different configuration for company ` + `"${incomingCompanyId}". This plugin is single-tenant and cannot ` + `safely serve multiple companies from one worker. If multi-company ` + `support is intended, set multiCompanyConfig: true on the plugin ` + `definition and key per-company state on context.companyId.`, ), { code: PLUGIN_RPC_ERROR_CODES.CROSS_TENANT_CONFIG }, ); } currentConfig = params.config; if (incomingCompanyId !== null) { configCompanyId = incomingCompanyId; } if (plugin.definition.onConfigChanged) { await plugin.definition.onConfigChanged(params.config, { companyId: incomingCompanyId, }); } } async function handleOnEvent(params: OnEventParams): Promise { const event = params.event; for (const registration of eventHandlers) { // Check event type match const exactMatch = registration.name === event.eventType; const wildcardPluginAll = registration.name === "plugin.*" && event.eventType.startsWith("plugin."); const wildcardPluginOne = registration.name.endsWith(".*") && event.eventType.startsWith(registration.name.slice(0, -1)); if (!exactMatch && !wildcardPluginAll && !wildcardPluginOne) continue; // Check filter if (registration.filter && !allowsEvent(registration.filter, event)) continue; try { await registration.fn(event); } catch (err) { // Log error but continue processing other handlers so one failing // handler doesn't prevent the rest from running. notifyHost("log", { level: "error", message: `Event handler for "${registration.name}" failed: ${ err instanceof Error ? err.message : String(err) }`, meta: { eventType: event.eventType, stack: err instanceof Error ? err.stack : undefined }, }); } } } async function handleRunJob(params: RunJobParams): Promise { const handler = jobHandlers.get(params.job.jobKey); if (!handler) { throw new Error(`No handler registered for job "${params.job.jobKey}"`); } await handler(params.job); } async function handleWebhook(params: PluginWebhookInput): Promise { if (!plugin.definition.onWebhook) { throw Object.assign( new Error("handleWebhook is not implemented by this plugin"), { code: PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED }, ); } await plugin.definition.onWebhook(params); } async function handleApiRequest(params: PluginApiRequestInput): Promise { if (!plugin.definition.onApiRequest) { throw Object.assign( new Error("handleApiRequest is not implemented by this plugin"), { code: PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED }, ); } return plugin.definition.onApiRequest(params); } async function handleGetData(params: GetDataParams): Promise { const handler = dataHandlers.get(params.key); if (!handler) { throw new Error(`No data handler registered for key "${params.key}"`); } return handler({ ...params.params, ...(params.companyId === undefined ? {} : { companyId: params.companyId }), ...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }), }); } function stringOrNull(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } function actorTypeOrSystem(value: unknown): PluginPerformActionActorContext["type"] { return value === "user" || value === "agent" || value === "system" ? value : "system"; } function actionContextFromParams(params: PerformActionParams): PluginPerformActionContext { const rawActor = params.actorContext && typeof params.actorContext === "object" ? params.actorContext : null; const actor = Object.freeze({ type: actorTypeOrSystem(rawActor?.type), userId: stringOrNull(rawActor?.userId), agentId: stringOrNull(rawActor?.agentId), runId: stringOrNull(rawActor?.runId), companyId: stringOrNull(rawActor?.companyId), }); return Object.freeze({ actor, companyId: actor.companyId, }); } async function handlePerformAction(params: PerformActionParams): Promise { const handler = actionHandlers.get(params.key); if (!handler) { throw new Error(`No action handler registered for key "${params.key}"`); } return handler( { ...params.params, ...(params.companyId === undefined ? {} : { companyId: params.companyId }), ...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }), }, actionContextFromParams(params), ); } async function handleExecuteTool(params: ExecuteToolParams): Promise { const entry = toolHandlers.get(params.toolName); if (!entry) { throw new Error(`No tool handler registered for "${params.toolName}"`); } return entry.fn(params.parameters, params.runContext); } async function handleDetectExternalObjects(params: DetectExternalObjectsParams) { if (!plugin.definition.onDetectExternalObjects) { throw methodNotImplemented("detectExternalObjects"); } return plugin.definition.onDetectExternalObjects(params); } async function handleResolveExternalObject(params: ResolveExternalObjectParams) { if (!plugin.definition.onResolveExternalObject) { throw methodNotImplemented("resolveExternalObject"); } return plugin.definition.onResolveExternalObject(params); } async function handleRefreshExternalObjects(params: RefreshExternalObjectsParams) { if (!plugin.definition.onRefreshExternalObjects) { throw methodNotImplemented("refreshExternalObjects"); } return plugin.definition.onRefreshExternalObjects(params); } function methodNotImplemented(method: string): Error & { code: number } { return Object.assign( new Error(`${method} is not implemented by this plugin`), { code: PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED }, ); } async function handleEnvironmentValidateConfig( params: PluginEnvironmentValidateConfigParams, ) { if (!plugin.definition.onEnvironmentValidateConfig) { throw methodNotImplemented("environmentValidateConfig"); } return plugin.definition.onEnvironmentValidateConfig(params); } async function handleEnvironmentProbe(params: PluginEnvironmentProbeParams) { if (!plugin.definition.onEnvironmentProbe) { throw methodNotImplemented("environmentProbe"); } return plugin.definition.onEnvironmentProbe(params); } async function handleEnvironmentAcquireLease(params: PluginEnvironmentAcquireLeaseParams) { if (!plugin.definition.onEnvironmentAcquireLease) { throw methodNotImplemented("environmentAcquireLease"); } return plugin.definition.onEnvironmentAcquireLease(params); } async function handleEnvironmentResumeLease(params: PluginEnvironmentResumeLeaseParams) { if (!plugin.definition.onEnvironmentResumeLease) { throw methodNotImplemented("environmentResumeLease"); } return plugin.definition.onEnvironmentResumeLease(params); } async function handleEnvironmentReleaseLease(params: PluginEnvironmentReleaseLeaseParams) { if (!plugin.definition.onEnvironmentReleaseLease) { throw methodNotImplemented("environmentReleaseLease"); } return plugin.definition.onEnvironmentReleaseLease(params); } async function handleEnvironmentDestroyLease(params: PluginEnvironmentDestroyLeaseParams) { if (!plugin.definition.onEnvironmentDestroyLease) { throw methodNotImplemented("environmentDestroyLease"); } return plugin.definition.onEnvironmentDestroyLease(params); } async function handleEnvironmentRealizeWorkspace(params: PluginEnvironmentRealizeWorkspaceParams) { if (!plugin.definition.onEnvironmentRealizeWorkspace) { throw methodNotImplemented("environmentRealizeWorkspace"); } return plugin.definition.onEnvironmentRealizeWorkspace(params); } async function handleEnvironmentExecute(params: PluginEnvironmentExecuteParams) { if (!plugin.definition.onEnvironmentExecute) { throw methodNotImplemented("environmentExecute"); } return plugin.definition.onEnvironmentExecute(params); } async function handleEnvironmentRunnerIngressEndpoint( params: PluginEnvironmentRunnerIngressEndpointParams, ) { if (!plugin.definition.onEnvironmentRunnerIngressEndpoint) { throw methodNotImplemented("environmentRunnerIngressEndpoint"); } return plugin.definition.onEnvironmentRunnerIngressEndpoint(params); } async function handleEnvironmentSyncIn(params: PluginEnvironmentSyncInParams) { if (!plugin.definition.onEnvironmentSyncIn) { throw methodNotImplemented("environmentSyncIn"); } return plugin.definition.onEnvironmentSyncIn(params); } async function handleEnvironmentSyncOut(params: PluginEnvironmentSyncOutParams) { if (!plugin.definition.onEnvironmentSyncOut) { throw methodNotImplemented("environmentSyncOut"); } return plugin.definition.onEnvironmentSyncOut(params); } async function handleEnvironmentStartInteractiveSetup(params: PluginEnvironmentStartInteractiveSetupParams) { if (!plugin.definition.onEnvironmentStartInteractiveSetup) { throw methodNotImplemented("environmentStartInteractiveSetup"); } return plugin.definition.onEnvironmentStartInteractiveSetup(params); } async function handleEnvironmentGetInteractiveSetup(params: PluginEnvironmentGetInteractiveSetupParams) { if (!plugin.definition.onEnvironmentGetInteractiveSetup) { throw methodNotImplemented("environmentGetInteractiveSetup"); } return plugin.definition.onEnvironmentGetInteractiveSetup(params); } async function handleEnvironmentCaptureTemplate(params: PluginEnvironmentCaptureTemplateParams) { if (!plugin.definition.onEnvironmentCaptureTemplate) { throw methodNotImplemented("environmentCaptureTemplate"); } return plugin.definition.onEnvironmentCaptureTemplate(params); } async function handleEnvironmentCancelInteractiveSetup(params: PluginEnvironmentCancelInteractiveSetupParams) { if (!plugin.definition.onEnvironmentCancelInteractiveSetup) { throw methodNotImplemented("environmentCancelInteractiveSetup"); } return plugin.definition.onEnvironmentCancelInteractiveSetup(params); } async function handleEnvironmentDeleteTemplate(params: PluginEnvironmentDeleteTemplateParams) { if (!plugin.definition.onEnvironmentDeleteTemplate) { throw methodNotImplemented("environmentDeleteTemplate"); } return plugin.definition.onEnvironmentDeleteTemplate(params); } async function handleLoginPtyOpen(params: PluginLoginPtyOpenParams) { if (!plugin.definition.onLoginPtyOpen) { throw methodNotImplemented("loginPtyOpen"); } return plugin.definition.onLoginPtyOpen(params); } async function handleLoginPtyInput(params: PluginLoginPtyInputParams) { if (!plugin.definition.onLoginPtyInput) { throw methodNotImplemented("loginPtyInput"); } return plugin.definition.onLoginPtyInput(params); } async function handleLoginPtyStop(params: PluginLoginPtyStopParams) { if (!plugin.definition.onLoginPtyStop) { throw methodNotImplemented("loginPtyStop"); } return plugin.definition.onLoginPtyStop(params); } async function handleLoginPtyClose(params: PluginLoginPtyCloseParams) { if (!plugin.definition.onLoginPtyClose) { throw methodNotImplemented("loginPtyClose"); } return plugin.definition.onLoginPtyClose(params); } async function handleDuplexChannelOpen(params: PluginDuplexChannelOpenParams) { if (!plugin.definition.onDuplexChannelOpen) { throw methodNotImplemented("duplexChannelOpen"); } return plugin.definition.onDuplexChannelOpen(params); } async function handleDuplexChannelWrite(params: PluginDuplexChannelWriteParams) { if (!plugin.definition.onDuplexChannelWrite) { throw methodNotImplemented("duplexChannelWrite"); } return plugin.definition.onDuplexChannelWrite(params); } async function handleDuplexChannelStop(params: PluginDuplexChannelStopParams) { if (!plugin.definition.onDuplexChannelStop) { throw methodNotImplemented("duplexChannelStop"); } return plugin.definition.onDuplexChannelStop(params); } async function handleDuplexChannelClose(params: PluginDuplexChannelCloseParams) { if (!plugin.definition.onDuplexChannelClose) { throw methodNotImplemented("duplexChannelClose"); } return plugin.definition.onDuplexChannelClose(params); } // ----------------------------------------------------------------------- // Event filter helper // ----------------------------------------------------------------------- function allowsEvent(filter: EventFilter, event: PluginEvent): boolean { const payload = event.payload as Record | undefined; if (filter.companyId !== undefined) { const companyId = event.companyId ?? String(payload?.companyId ?? ""); if (companyId !== filter.companyId) return false; } if (filter.projectId !== undefined) { const projectId = event.entityType === "project" ? event.entityId : String(payload?.projectId ?? ""); if (projectId !== filter.projectId) return false; } if (filter.agentId !== undefined) { const agentId = event.entityType === "agent" ? event.entityId : String(payload?.agentId ?? ""); if (agentId !== filter.agentId) return false; } return true; } // ----------------------------------------------------------------------- // Inbound response handling (host → worker, response to our outbound call) // ----------------------------------------------------------------------- function handleHostResponse(response: JsonRpcResponse): void { const id = response.id; if (id === null || id === undefined) return; const pending = pendingRequests.get(id); if (!pending) return; clearTimeout(pending.timer); pendingRequests.delete(id); pending.resolve(response); } // ----------------------------------------------------------------------- // Incoming line handler // ----------------------------------------------------------------------- function handleLine(line: string): void { if (!line.trim()) return; let message: unknown; try { message = parseMessage(line); } catch (err) { if (err instanceof JsonRpcParseError) { // Send parse error response sendMessage( createErrorResponse( null, JSONRPC_ERROR_CODES.PARSE_ERROR, `Parse error: ${err.message}`, ), ); } return; } if (isJsonRpcResponse(message)) { // This is a response to one of our outbound worker→host calls handleHostResponse(message); } else if (isJsonRpcRequest(message)) { // This is a host→worker RPC call — dispatch it handleHostRequest(message as JsonRpcRequest).catch((err) => { // Unhandled error in the async handler — send error response const errorMessage = err instanceof Error ? err.message : String(err); const errorCode = (err as any)?.code ?? PLUGIN_RPC_ERROR_CODES.WORKER_ERROR; try { sendMessage( createErrorResponse( (message as JsonRpcRequest).id, typeof errorCode === "number" ? errorCode : PLUGIN_RPC_ERROR_CODES.WORKER_ERROR, errorMessage, ), ); } catch { // Cannot send response, stdout may be closed } }); } else if (isJsonRpcNotification(message)) { // Dispatch host→worker push notifications const notif = message as JsonRpcNotification & { method: string; params?: unknown }; const runNotification = (fn: () => void | Promise) => { if (notif.paperclipInvocation) { return invocationContextStorage.run(notif.paperclipInvocation, fn); } return fn(); }; if (notif.method === "agents.sessions.event" && notif.params) { const event = notif.params as AgentSessionEvent; const cb = sessionEventCallbacks.get(event.sessionId); if (cb) cb(event); } else if (notif.method === "onEvent" && notif.params) { // Plugin event bus notifications — dispatch to registered event handlers Promise.resolve(runNotification(() => handleOnEvent(notif.params as OnEventParams))).catch((err) => { notifyHost("log", { level: "error", message: `Failed to handle event notification: ${err instanceof Error ? err.message : String(err)}`, }); }); } } } // ----------------------------------------------------------------------- // Cleanup // ----------------------------------------------------------------------- function cleanup(): void { running = false; // Close readline if (readline) { readline.close(); readline = null; } // Reject all pending outbound calls for (const [id, pending] of pendingRequests) { clearTimeout(pending.timer); pending.resolve( createErrorResponse( id, PLUGIN_RPC_ERROR_CODES.WORKER_UNAVAILABLE, "Worker RPC host is shutting down", ) as JsonRpcResponse, ); } pendingRequests.clear(); sessionEventCallbacks.clear(); } // ----------------------------------------------------------------------- // Bootstrap: wire up stdin readline // ----------------------------------------------------------------------- let readline: ReadlineInterface | null = createInterface({ input: stdinStream as NodeJS.ReadableStream, crlfDelay: Infinity, }); readline.on("line", handleLine); // If stdin closes, we should exit gracefully readline.on("close", () => { if (running) { cleanup(); if (!options.stdin && !options.stdout) { process.exit(0); } } }); // Handle uncaught errors in the worker process. // Only install these when using the real process streams (not in tests // where the caller provides custom streams). if (!options.stdin && !options.stdout) { process.on("uncaughtException", (err) => { notifyHost("log", { level: "error", message: `Uncaught exception: ${err.message}`, meta: { stack: err.stack }, }); // Give the notification a moment to flush, then exit setTimeout(() => process.exit(1), 100); }); process.on("unhandledRejection", (reason) => { const message = reason instanceof Error ? reason.message : String(reason); const stack = reason instanceof Error ? reason.stack : undefined; notifyHost("log", { level: "error", message: `Unhandled rejection: ${message}`, meta: { stack }, }); }); } // ----------------------------------------------------------------------- // Return the handle // ----------------------------------------------------------------------- return { get running() { return running; }, stop() { cleanup(); }, }; }