2377 lines
84 KiB
TypeScript
2377 lines
84 KiB
TypeScript
/**
|
|
* 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<void>;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<string, unknown>)
|
|
.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<string, unknown> = {};
|
|
// 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<PluginInvocationContext>();
|
|
|
|
// Plugin handler registrations (populated during setup())
|
|
const eventHandlers: EventRegistration[] = [];
|
|
const jobHandlers = new Map<string, (job: PluginJobContext) => Promise<void>>();
|
|
const launcherRegistrations = new Map<string, PluginLauncherRegistration>();
|
|
const dataHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
|
const actionHandlers = new Map<
|
|
string,
|
|
(params: Record<string, unknown>, context: PluginPerformActionContext) => Promise<unknown>
|
|
>();
|
|
const toolHandlers = new Map<string, {
|
|
declaration: Pick<import("@paperclipai/shared").PluginToolDeclaration, "displayName" | "description" | "parametersSchema">;
|
|
fn: (params: unknown, runCtx: ToolRunContext) => Promise<ToolResult>;
|
|
}>();
|
|
|
|
// Agent session event callbacks (populated by sendMessage, cleared by close)
|
|
const sessionEventCallbacks = new Map<string, (event: AgentSessionEvent) => void>();
|
|
|
|
// Pending outbound (worker→host) requests
|
|
const pendingRequests = new Map<string | number, {
|
|
resolve: (response: JsonRpcResponse) => void;
|
|
timer: ReturnType<typeof setTimeout>;
|
|
}>();
|
|
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<M extends WorkerToHostMethodName>(
|
|
method: M,
|
|
params: WorkerToHostMethods[M][0],
|
|
timeoutMs?: number,
|
|
): Promise<WorkerToHostMethods[M][1]> {
|
|
return new Promise<WorkerToHostMethods[M][1]>((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 = <T>(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<void>),
|
|
maybeFn?: (event: PluginEvent) => Promise<void>,
|
|
): () => 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<void> {
|
|
await callHost("events.emit", { name, companyId, payload });
|
|
},
|
|
},
|
|
|
|
jobs: {
|
|
register(key: string, fn: (job: PluginJobContext) => Promise<void>): void {
|
|
jobHandlers.set(key, fn);
|
|
},
|
|
},
|
|
|
|
launchers: {
|
|
register(launcher: PluginLauncherRegistration): void {
|
|
launcherRegistrations.set(launcher.id, launcher);
|
|
},
|
|
},
|
|
|
|
db: {
|
|
get namespace() {
|
|
return databaseNamespace ?? "";
|
|
},
|
|
async query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]> {
|
|
return callHost("db.query", { sql, params }) as Promise<T[]>;
|
|
},
|
|
async execute(sql: string, params?: unknown[]) {
|
|
return callHost("db.execute", { sql, params });
|
|
},
|
|
},
|
|
|
|
http: {
|
|
async fetch(url: string, init?: RequestInit): Promise<Response> {
|
|
const serializedInit: Record<string, unknown> = {};
|
|
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<string, string> = {};
|
|
init.headers.forEach((v, k) => { obj[k] = v; });
|
|
serializedInit.headers = obj;
|
|
} else if (Array.isArray(init.headers)) {
|
|
const obj: Record<string, string> = {};
|
|
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<string> {
|
|
return callHost("secrets.resolve", {
|
|
secretRef,
|
|
companyId: options.companyId,
|
|
configPath: options.configPath,
|
|
});
|
|
},
|
|
},
|
|
|
|
activity: {
|
|
async log(entry): Promise<void> {
|
|
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<unknown> {
|
|
return callHost("state.get", {
|
|
scopeKind: input.scopeKind,
|
|
scopeId: input.scopeId,
|
|
namespace: input.namespace,
|
|
stateKey: input.stateKey,
|
|
});
|
|
},
|
|
|
|
async set(input: ScopeKey, value: unknown): Promise<void> {
|
|
await callHost("state.set", {
|
|
scopeKind: input.scopeKind,
|
|
scopeId: input.scopeId,
|
|
namespace: input.namespace,
|
|
stateKey: input.stateKey,
|
|
value,
|
|
});
|
|
},
|
|
|
|
async delete(input: ScopeKey): Promise<void> {
|
|
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<string, unknown>),
|
|
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<SuggestTasksInteraction> {
|
|
return callHost("issues.createInteraction", {
|
|
issueId,
|
|
companyId,
|
|
interaction: {
|
|
...interaction,
|
|
kind: "suggest_tasks",
|
|
},
|
|
authorAgentId: options?.authorAgentId,
|
|
}) as Promise<SuggestTasksInteraction>;
|
|
},
|
|
|
|
async askUserQuestions(
|
|
issueId: string,
|
|
interaction,
|
|
companyId: string,
|
|
options?: { authorAgentId?: string },
|
|
): Promise<AskUserQuestionsInteraction> {
|
|
return callHost("issues.createInteraction", {
|
|
issueId,
|
|
companyId,
|
|
interaction: {
|
|
...interaction,
|
|
kind: "ask_user_questions",
|
|
},
|
|
authorAgentId: options?.authorAgentId,
|
|
}) as Promise<AskUserQuestionsInteraction>;
|
|
},
|
|
|
|
async requestConfirmation(
|
|
issueId: string,
|
|
interaction,
|
|
companyId: string,
|
|
options?: { authorAgentId?: string },
|
|
): Promise<RequestConfirmationInteraction> {
|
|
return callHost("issues.createInteraction", {
|
|
issueId,
|
|
companyId,
|
|
interaction: {
|
|
...interaction,
|
|
kind: "request_confirmation",
|
|
},
|
|
authorAgentId: options?.authorAgentId,
|
|
}) as Promise<RequestConfirmationInteraction>;
|
|
},
|
|
|
|
async requestCheckboxConfirmation(
|
|
issueId: string,
|
|
interaction,
|
|
companyId: string,
|
|
options?: { authorAgentId?: string },
|
|
): Promise<RequestCheckboxConfirmationInteraction> {
|
|
return callHost("issues.createInteraction", {
|
|
issueId,
|
|
companyId,
|
|
interaction: {
|
|
...interaction,
|
|
kind: "request_checkbox_confirmation",
|
|
},
|
|
authorAgentId: options?.authorAgentId,
|
|
}) as Promise<RequestCheckboxConfirmationInteraction>;
|
|
},
|
|
|
|
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<string, unknown>,
|
|
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<string, unknown>) => Promise<unknown>): void {
|
|
dataHandlers.set(key, handler);
|
|
},
|
|
},
|
|
|
|
actions: {
|
|
register(
|
|
key: string,
|
|
handler: (params: Record<string, unknown>, context: PluginPerformActionContext) => Promise<unknown>,
|
|
): void {
|
|
actionHandlers.set(key, handler);
|
|
},
|
|
},
|
|
|
|
streams: (() => {
|
|
// Track channel → companyId so emit/close don't require companyId
|
|
const channelCompanyMap = new Map<string, string>();
|
|
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<import("@paperclipai/shared").PluginToolDeclaration, "displayName" | "description" | "parametersSchema">,
|
|
fn: (params: unknown, runCtx: ToolRunContext) => Promise<ToolResult>,
|
|
): void {
|
|
toolHandlers.set(name, { declaration, fn });
|
|
},
|
|
},
|
|
|
|
metrics: {
|
|
async write(name: string, value: number, tags?: Record<string, string>): Promise<void> {
|
|
await callHost("metrics.write", { name, value, tags });
|
|
},
|
|
},
|
|
|
|
telemetry: {
|
|
async track(
|
|
eventName: string,
|
|
dimensions?: Record<string, string | number | boolean>,
|
|
): Promise<void> {
|
|
await callHost("telemetry.track", { eventName, dimensions });
|
|
},
|
|
},
|
|
|
|
logger: {
|
|
info(message: string, meta?: Record<string, unknown>): void {
|
|
notifyHost("log", { level: "info", message, meta });
|
|
},
|
|
warn(message: string, meta?: Record<string, unknown>): void {
|
|
notifyHost("log", { level: "warn", message, meta });
|
|
},
|
|
error(message: string, meta?: Record<string, unknown>): void {
|
|
notifyHost("log", { level: "error", message, meta });
|
|
},
|
|
debug(message: string, meta?: Record<string, unknown>): void {
|
|
notifyHost("log", { level: "debug", message, meta });
|
|
},
|
|
},
|
|
|
|
tracer: {
|
|
startSpan(
|
|
name: string,
|
|
options?: { attributes?: Record<string, string | number | boolean> },
|
|
) {
|
|
// 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<string, string | number | boolean> = {
|
|
...(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<void> {
|
|
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<unknown> {
|
|
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<InitializeResult> {
|
|
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<PluginHealthDiagnostics> {
|
|
if (plugin.definition.onHealth) {
|
|
return plugin.definition.onHealth();
|
|
}
|
|
// Default: report OK if the worker is alive
|
|
return { status: "ok" };
|
|
}
|
|
|
|
async function handleShutdown(): Promise<void> {
|
|
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<PluginConfigValidationResult> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<unknown> {
|
|
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<unknown> {
|
|
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<unknown> {
|
|
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<ToolResult> {
|
|
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<string, unknown> | 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<void>) => {
|
|
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();
|
|
},
|
|
};
|
|
}
|