2624 lines
86 KiB
TypeScript
2624 lines
86 KiB
TypeScript
/**
|
||
* JSON-RPC 2.0 message types and protocol helpers for the host ↔ worker IPC
|
||
* channel.
|
||
*
|
||
* The Paperclip plugin runtime uses JSON-RPC 2.0 over stdio to communicate
|
||
* between the host process and each plugin worker process. This module defines:
|
||
*
|
||
* - Core JSON-RPC 2.0 envelope types (request, response, notification, error)
|
||
* - Standard and plugin-specific error codes
|
||
* - Typed method maps for host→worker and worker→host calls
|
||
* - Helper functions for creating well-formed messages
|
||
*
|
||
* @see PLUGIN_SPEC.md §12.1 — Process Model
|
||
* @see PLUGIN_SPEC.md §13 — Host-Worker Protocol
|
||
* @see https://www.jsonrpc.org/specification
|
||
*/
|
||
|
||
import type {
|
||
PaperclipPluginManifestV1,
|
||
PluginLauncherBounds,
|
||
PluginLauncherRenderContextSnapshot,
|
||
PluginLauncherRenderEnvironment,
|
||
PluginStateScopeKind,
|
||
Company,
|
||
Project,
|
||
Issue,
|
||
IssueComment,
|
||
IssueDocument,
|
||
IssueDocumentSummary,
|
||
IssueAssigneeAdapterOverrides,
|
||
IssueAttachment,
|
||
IssueThreadInteraction,
|
||
CreateIssueThreadInteraction,
|
||
Approval,
|
||
PluginManagedAgentResolution,
|
||
PluginManagedProjectResolution,
|
||
PluginManagedRoutineResolution,
|
||
PluginManagedSkillResolution,
|
||
Routine,
|
||
RoutineRun,
|
||
Agent,
|
||
Goal,
|
||
PluginLocalFolderDeclaration,
|
||
PrincipalPermissionGrant,
|
||
ExternalObjectStatusCategory,
|
||
ExternalObjectStatusTone,
|
||
ExternalObjectLivenessState,
|
||
ExternalObjectMentionConfidence,
|
||
ExternalObjectMentionSourceKind,
|
||
EnvSecretRefBinding,
|
||
} from "@paperclipai/shared";
|
||
export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared";
|
||
|
||
import type {
|
||
PluginEvent,
|
||
PluginIssueCheckoutOwnership,
|
||
PluginIssueOrchestrationSummary,
|
||
PluginIssueRelationSummary,
|
||
PluginIssueSubtree,
|
||
PluginIssueAttachmentContent,
|
||
PluginIssueWakeupBatchResult,
|
||
PluginIssueWakeupResult,
|
||
PluginJobContext,
|
||
PluginExecutionWorkspaceMetadata,
|
||
PluginWorkspace,
|
||
ToolRunContext,
|
||
ToolResult,
|
||
PluginLocalFolderListing,
|
||
PluginLocalFolderStatus,
|
||
PluginAccessInvite,
|
||
PluginAccessMember,
|
||
PluginAssignmentPreviewInput,
|
||
PluginAuthorizationAuditEntry,
|
||
PluginAuthorizationDecisionResult,
|
||
PluginAuthorizationPolicyRecord,
|
||
PluginAuthorizationPolicySummary,
|
||
} from "./types.js";
|
||
import type {
|
||
PluginHealthDiagnostics,
|
||
PluginApiRequestInput,
|
||
PluginApiResponse,
|
||
PluginConfigValidationResult,
|
||
PluginWebhookInput,
|
||
} from "./define-plugin.js";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// JSON-RPC 2.0 — Core Protocol Types
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** The JSON-RPC protocol version. Always `"2.0"`. */
|
||
export const JSONRPC_VERSION = "2.0" as const;
|
||
|
||
/**
|
||
* A unique request identifier. JSON-RPC 2.0 allows strings or numbers;
|
||
* we use strings (UUIDs or monotonic counters) for all Paperclip messages.
|
||
*/
|
||
export type JsonRpcId = string | number;
|
||
|
||
/**
|
||
* Host-owned scope attached to a host→worker invocation. Workers may echo the
|
||
* invocation id on nested worker→host calls, but they never author this scope.
|
||
*/
|
||
export interface JsonRpcInvocationScope {
|
||
readonly companyId?: string | null;
|
||
}
|
||
|
||
export interface JsonRpcInvocationContext {
|
||
readonly id: string;
|
||
readonly scope: JsonRpcInvocationScope;
|
||
}
|
||
|
||
/**
|
||
* A JSON-RPC 2.0 request message.
|
||
*
|
||
* The host sends requests to the worker (or vice versa) and expects a
|
||
* matching response with the same `id`.
|
||
*/
|
||
export interface JsonRpcRequest<
|
||
TMethod extends string = string,
|
||
TParams = unknown,
|
||
> {
|
||
readonly jsonrpc: typeof JSONRPC_VERSION;
|
||
/** Unique request identifier. Must be echoed in the response. */
|
||
readonly id: JsonRpcId;
|
||
/** The RPC method name to invoke. */
|
||
readonly method: TMethod;
|
||
/** Structured parameters for the method call. */
|
||
readonly params: TParams;
|
||
/**
|
||
* Host-issued metadata for the top-level plugin invocation that is currently
|
||
* executing. The worker treats this as opaque and echoes only the id on
|
||
* worker→host calls made from the same async execution context.
|
||
*/
|
||
readonly paperclipInvocation?: PluginInvocationContext;
|
||
/** Opaque top-level invocation id echoed by worker→host requests. */
|
||
readonly paperclipInvocationId?: string;
|
||
}
|
||
|
||
/**
|
||
* A JSON-RPC 2.0 success response.
|
||
*/
|
||
export interface JsonRpcSuccessResponse<TResult = unknown> {
|
||
readonly jsonrpc: typeof JSONRPC_VERSION;
|
||
/** Echoed request identifier. */
|
||
readonly id: JsonRpcId;
|
||
/** The method return value. */
|
||
readonly result: TResult;
|
||
readonly error?: never;
|
||
}
|
||
|
||
/**
|
||
* A JSON-RPC 2.0 error object embedded in an error response.
|
||
*/
|
||
export interface JsonRpcError<TData = unknown> {
|
||
/** Machine-readable error code. */
|
||
readonly code: number;
|
||
/** Human-readable error message. */
|
||
readonly message: string;
|
||
/** Optional structured error data. */
|
||
readonly data?: TData;
|
||
}
|
||
|
||
/**
|
||
* A JSON-RPC 2.0 error response.
|
||
*/
|
||
export interface JsonRpcErrorResponse<TData = unknown> {
|
||
readonly jsonrpc: typeof JSONRPC_VERSION;
|
||
/** Echoed request identifier. */
|
||
readonly id: JsonRpcId | null;
|
||
readonly result?: never;
|
||
/** The error object. */
|
||
readonly error: JsonRpcError<TData>;
|
||
}
|
||
|
||
/**
|
||
* A JSON-RPC 2.0 response — either success or error.
|
||
*/
|
||
export type JsonRpcResponse<TResult = unknown, TData = unknown> =
|
||
| JsonRpcSuccessResponse<TResult>
|
||
| JsonRpcErrorResponse<TData>;
|
||
|
||
/**
|
||
* A JSON-RPC 2.0 notification (a request with no `id`).
|
||
*
|
||
* Notifications are fire-and-forget — no response is expected.
|
||
*/
|
||
export interface JsonRpcNotification<
|
||
TMethod extends string = string,
|
||
TParams = unknown,
|
||
> {
|
||
readonly jsonrpc: typeof JSONRPC_VERSION;
|
||
readonly id?: never;
|
||
/** The notification method name. */
|
||
readonly method: TMethod;
|
||
/** Structured parameters for the notification. */
|
||
readonly params: TParams;
|
||
/**
|
||
* Host-issued metadata for host→worker push notifications such as events.
|
||
* Worker→host notifications echo only `paperclipInvocationId`.
|
||
*/
|
||
readonly paperclipInvocation?: PluginInvocationContext;
|
||
/** Opaque top-level invocation id echoed by worker→host notifications. */
|
||
readonly paperclipInvocationId?: string;
|
||
}
|
||
|
||
/**
|
||
* Any well-formed JSON-RPC 2.0 message (request, response, or notification).
|
||
*/
|
||
export type JsonRpcMessage =
|
||
| JsonRpcRequest
|
||
| JsonRpcResponse
|
||
| JsonRpcNotification;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Error Codes
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Standard JSON-RPC 2.0 error codes.
|
||
*
|
||
* @see https://www.jsonrpc.org/specification#error_object
|
||
*/
|
||
export const JSONRPC_ERROR_CODES = {
|
||
/** Invalid JSON was received by the server. */
|
||
PARSE_ERROR: -32700,
|
||
/** The JSON sent is not a valid Request object. */
|
||
INVALID_REQUEST: -32600,
|
||
/** The method does not exist or is not available. */
|
||
METHOD_NOT_FOUND: -32601,
|
||
/** Invalid method parameter(s). */
|
||
INVALID_PARAMS: -32602,
|
||
/** Internal JSON-RPC error. */
|
||
INTERNAL_ERROR: -32603,
|
||
} as const;
|
||
|
||
export type JsonRpcErrorCode =
|
||
(typeof JSONRPC_ERROR_CODES)[keyof typeof JSONRPC_ERROR_CODES];
|
||
|
||
/**
|
||
* Paperclip plugin-specific error codes.
|
||
*
|
||
* These live in the JSON-RPC "server error" reserved range (-32000 to -32099)
|
||
* as specified by JSON-RPC 2.0 for implementation-defined server errors.
|
||
*
|
||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||
*/
|
||
export const PLUGIN_RPC_ERROR_CODES = {
|
||
/** The worker process is not running or not reachable. */
|
||
WORKER_UNAVAILABLE: -32000,
|
||
/** The plugin does not have the required capability for this operation. */
|
||
CAPABILITY_DENIED: -32001,
|
||
/** The worker reported an unhandled error during method execution. */
|
||
WORKER_ERROR: -32002,
|
||
/** The method call timed out waiting for the worker response. */
|
||
TIMEOUT: -32003,
|
||
/** The worker does not implement the requested optional method. */
|
||
METHOD_NOT_IMPLEMENTED: -32004,
|
||
/** The worker→host call attempted to escape the current invocation company scope. */
|
||
INVOCATION_SCOPE_DENIED: -32005,
|
||
/**
|
||
* A `configChanged` delivery would have collapsed a single-tenant worker onto
|
||
* a second, distinct company's configuration. The worker fails closed instead
|
||
* of silently overwriting the already-applied tenant's config. A plugin that
|
||
* genuinely serves multiple companies from one worker must opt in via
|
||
* `multiCompanyConfig: true` on its definition.
|
||
*/
|
||
CROSS_TENANT_CONFIG: -32006,
|
||
/** A catch-all for errors that do not fit other categories. */
|
||
UNKNOWN: -32099,
|
||
} as const;
|
||
|
||
export type PluginRpcErrorCode =
|
||
(typeof PLUGIN_RPC_ERROR_CODES)[keyof typeof PLUGIN_RPC_ERROR_CODES];
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Invocation scope metadata
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Company scope attached by the host to one top-level plugin invocation.
|
||
* Absence of this metadata means the invocation is instance/global scoped.
|
||
*/
|
||
export interface PluginInvocationScope {
|
||
companyId: string;
|
||
}
|
||
|
||
/**
|
||
* Opaque invocation metadata generated by the host. Workers must not derive or
|
||
* mutate this. They only echo the id on nested worker→host RPC calls.
|
||
*/
|
||
export interface PluginInvocationContext {
|
||
id: string;
|
||
scope: PluginInvocationScope;
|
||
/**
|
||
* An optional W3C `traceparent` for the active host span. The host mints it
|
||
* per call from the active startup span. The worker treats it as opaque: it
|
||
* tags its provider span with it and never derives parentage from it. The host
|
||
* mints the parentage from its own invocation record, so a worker can never
|
||
* forge a parent.
|
||
*/
|
||
traceparent?: string;
|
||
}
|
||
|
||
/**
|
||
* Context provided to host-side worker→host handlers after the worker echoes a
|
||
* host-issued invocation id.
|
||
*/
|
||
export interface WorkerHostCallContext {
|
||
invocationScope?: PluginInvocationScope | null;
|
||
invalidInvocationScope?: boolean;
|
||
/**
|
||
* The W3C `traceparent` the host minted for the echoed invocation. The host
|
||
* recovers it from its own invocation record, not from the worker, so a worker
|
||
* can never forge a span parent. The span host handler validates and uses it.
|
||
*/
|
||
traceparent?: string;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Host → Worker Method Signatures (§13 Host-Worker Protocol)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Input for the `initialize` RPC method.
|
||
*
|
||
* @see PLUGIN_SPEC.md §13.1 — `initialize`
|
||
*/
|
||
export interface InitializeParams {
|
||
/** Full plugin manifest snapshot. */
|
||
manifest: PaperclipPluginManifestV1;
|
||
/** Bootstrap configuration. Company-scoped config is read via `ctx.config.get(companyId)`. */
|
||
config: Record<string, unknown>;
|
||
/** Instance-level metadata. */
|
||
instanceInfo: {
|
||
/** UUID of this Paperclip instance. */
|
||
instanceId: string;
|
||
/** Semver version of the running Paperclip host. */
|
||
hostVersion: string;
|
||
};
|
||
/** Host API version. */
|
||
apiVersion: number;
|
||
/** Host-derived plugin database namespace, when the manifest declares database access. */
|
||
databaseNamespace?: string | null;
|
||
}
|
||
|
||
/**
|
||
* Result returned by the `initialize` RPC method.
|
||
*/
|
||
export interface InitializeResult {
|
||
/** Whether initialization succeeded. */
|
||
ok: boolean;
|
||
/** Optional methods the worker has implemented (e.g. "validateConfig", "onEvent"). */
|
||
supportedMethods?: string[];
|
||
}
|
||
|
||
/**
|
||
* Input for the `configChanged` RPC method.
|
||
*
|
||
* @see PLUGIN_SPEC.md §13.4 — `configChanged`
|
||
*/
|
||
export interface ConfigChangedParams {
|
||
/** The newly resolved company-scoped configuration. */
|
||
config: Record<string, unknown>;
|
||
/** Company whose plugin config changed. */
|
||
companyId?: string | null;
|
||
}
|
||
|
||
/**
|
||
* Input for the `validateConfig` RPC method.
|
||
*
|
||
* @see PLUGIN_SPEC.md §13.3 — `validateConfig`
|
||
*/
|
||
export interface ValidateConfigParams {
|
||
/** The configuration to validate. */
|
||
config: Record<string, unknown>;
|
||
}
|
||
|
||
/**
|
||
* Input for the `onEvent` RPC method.
|
||
*
|
||
* @see PLUGIN_SPEC.md §13.5 — `onEvent`
|
||
*/
|
||
export interface OnEventParams {
|
||
/** The domain event to deliver. */
|
||
event: PluginEvent;
|
||
}
|
||
|
||
/**
|
||
* Input for the `runJob` RPC method.
|
||
*
|
||
* @see PLUGIN_SPEC.md §13.6 — `runJob`
|
||
*/
|
||
export interface RunJobParams {
|
||
/** Job execution context. */
|
||
job: PluginJobContext;
|
||
}
|
||
|
||
/**
|
||
* Input for the `getData` RPC method.
|
||
*
|
||
* @see PLUGIN_SPEC.md §13.8 — `getData`
|
||
*/
|
||
export interface GetDataParams {
|
||
/** Plugin-defined data key (e.g. `"sync-health"`). */
|
||
key: string;
|
||
/** Host-authorized active company scope, when this bridge call is company-scoped. */
|
||
companyId?: string | null;
|
||
/** Context and query parameters from the UI. */
|
||
params: Record<string, unknown>;
|
||
/** Optional launcher/container metadata from the host render environment. */
|
||
renderEnvironment?: PluginLauncherRenderContextSnapshot | null;
|
||
}
|
||
|
||
/**
|
||
* Input for the `performAction` RPC method.
|
||
*
|
||
* @see PLUGIN_SPEC.md §13.9 — `performAction`
|
||
*/
|
||
export type PluginPerformActionActorType = "user" | "agent" | "system";
|
||
|
||
export interface PluginPerformActionActorContext {
|
||
/** Authenticated principal type resolved by the Paperclip host. */
|
||
type: PluginPerformActionActorType;
|
||
/** Authenticated board user id when `type === "user"`, otherwise null. */
|
||
userId: string | null;
|
||
/** Authenticated agent id when `type === "agent"`, otherwise null. */
|
||
agentId: string | null;
|
||
/** Authenticated heartbeat/run id when available. */
|
||
runId: string | null;
|
||
/** Company id authorized by the host bridge for this action, when applicable. */
|
||
companyId: string | null;
|
||
}
|
||
|
||
export interface PluginPerformActionContext {
|
||
/** Immutable authenticated actor context supplied by the host. */
|
||
actor: Readonly<PluginPerformActionActorContext>;
|
||
/** Convenience alias for `actor.companyId`. */
|
||
companyId: string | null;
|
||
}
|
||
|
||
export interface PerformActionParams {
|
||
/** Plugin-defined action key (e.g. `"resync"`). */
|
||
key: string;
|
||
/** Host-authorized active company scope, when this bridge call is company-scoped. */
|
||
companyId?: string | null;
|
||
/** Action parameters from the UI. */
|
||
params: Record<string, unknown>;
|
||
/** Authenticated actor context resolved by the host, never by caller params. */
|
||
actorContext?: PluginPerformActionActorContext | null;
|
||
/** Optional launcher/container metadata from the host render environment. */
|
||
renderEnvironment?: PluginLauncherRenderContextSnapshot | null;
|
||
}
|
||
|
||
/**
|
||
* Input for the `executeTool` RPC method.
|
||
*
|
||
* @see PLUGIN_SPEC.md §13.10 — `executeTool`
|
||
*/
|
||
export interface ExecuteToolParams {
|
||
/** Tool name (without plugin namespace prefix). */
|
||
toolName: string;
|
||
/** Parsed parameters matching the tool's declared schema. */
|
||
parameters: unknown;
|
||
/** Agent run context. */
|
||
runContext: ToolRunContext;
|
||
}
|
||
|
||
export interface PluginExternalObjectUrlCandidate {
|
||
sanitizedCanonicalUrl: string;
|
||
sanitizedDisplayUrl: string;
|
||
canonicalIdentityHash: string;
|
||
canonicalIdentity: Record<string, unknown>;
|
||
redactedMatchedText: string;
|
||
}
|
||
|
||
export interface PluginExternalObjectSourceContext {
|
||
companyId: string;
|
||
sourceIssueId: string;
|
||
sourceKind: ExternalObjectMentionSourceKind;
|
||
sourceRecordId: string | null;
|
||
documentKey: string | null;
|
||
propertyKey: string | null;
|
||
}
|
||
|
||
export interface DetectExternalObjectsParams {
|
||
companyId: string;
|
||
urls: PluginExternalObjectUrlCandidate[];
|
||
sourceContext: PluginExternalObjectSourceContext;
|
||
}
|
||
|
||
export interface PluginExternalObjectDetection {
|
||
urlIdentityHash: string;
|
||
providerKey: string;
|
||
objectType: string;
|
||
externalId: string;
|
||
displayKey?: string | null;
|
||
iconKey?: string | null;
|
||
displayTitle?: string | null;
|
||
confidence?: ExternalObjectMentionConfidence;
|
||
}
|
||
|
||
export interface DetectExternalObjectsResult {
|
||
detections: PluginExternalObjectDetection[];
|
||
}
|
||
|
||
export interface PluginExternalObjectRecordSnapshot {
|
||
id: string;
|
||
companyId: string;
|
||
providerKey: string;
|
||
objectType: string;
|
||
externalId: string;
|
||
sanitizedCanonicalUrl: string | null;
|
||
canonicalIdentityHash: string | null;
|
||
displayKey: string | null;
|
||
iconKey: string | null;
|
||
displayTitle: string | null;
|
||
statusKey: string | null;
|
||
statusLabel: string | null;
|
||
statusIconKey: string | null;
|
||
statusCategory: ExternalObjectStatusCategory;
|
||
statusTone: ExternalObjectStatusTone;
|
||
liveness: ExternalObjectLivenessState;
|
||
isTerminal: boolean;
|
||
data: Record<string, unknown>;
|
||
remoteVersion: string | null;
|
||
etag: string | null;
|
||
}
|
||
|
||
export interface ResolveExternalObjectParams {
|
||
companyId: string;
|
||
providerKey: string;
|
||
objectType: string;
|
||
externalId: string;
|
||
object: PluginExternalObjectRecordSnapshot;
|
||
}
|
||
|
||
export interface PluginExternalObjectResolvedSnapshot {
|
||
displayKey?: string | null;
|
||
iconKey?: string | null;
|
||
displayTitle?: string | null;
|
||
statusKey?: string | null;
|
||
statusLabel?: string | null;
|
||
statusIconKey?: string | null;
|
||
statusCategory: ExternalObjectStatusCategory;
|
||
statusTone: ExternalObjectStatusTone;
|
||
isTerminal?: boolean;
|
||
data?: Record<string, unknown>;
|
||
remoteVersion?: string | null;
|
||
etag?: string | null;
|
||
ttlSeconds?: number;
|
||
}
|
||
|
||
export type PluginExternalObjectResolveResult =
|
||
| { ok: true; snapshot: PluginExternalObjectResolvedSnapshot }
|
||
| {
|
||
ok: false;
|
||
liveness: Extract<ExternalObjectLivenessState, "auth_required" | "unreachable">;
|
||
errorCode: string;
|
||
errorMessage?: string | null;
|
||
retryAfterSeconds?: number;
|
||
};
|
||
|
||
export interface RefreshExternalObjectsParams {
|
||
companyId: string;
|
||
objects: PluginExternalObjectRecordSnapshot[];
|
||
}
|
||
|
||
export interface RefreshExternalObjectsResult {
|
||
results: Array<{
|
||
objectId: string;
|
||
result: PluginExternalObjectResolveResult;
|
||
}>;
|
||
}
|
||
|
||
export interface PluginEnvironmentDiagnostic {
|
||
severity: "info" | "warning" | "error";
|
||
message: string;
|
||
code?: string;
|
||
details?: Record<string, unknown>;
|
||
}
|
||
|
||
export interface PluginEnvironmentDriverBaseParams {
|
||
driverKey: string;
|
||
companyId: string;
|
||
environmentId: string;
|
||
issueId?: string | null;
|
||
config: Record<string, unknown>;
|
||
}
|
||
|
||
export interface PluginEnvironmentValidateConfigParams {
|
||
driverKey: string;
|
||
config: Record<string, unknown>;
|
||
}
|
||
|
||
export interface PluginEnvironmentValidationResult {
|
||
ok: boolean;
|
||
warnings?: string[];
|
||
errors?: string[];
|
||
normalizedConfig?: Record<string, unknown>;
|
||
}
|
||
|
||
export interface PluginEnvironmentProbeParams extends PluginEnvironmentDriverBaseParams {}
|
||
|
||
export interface PluginEnvironmentProbeResult {
|
||
ok: boolean;
|
||
summary?: string;
|
||
diagnostics?: PluginEnvironmentDiagnostic[];
|
||
metadata?: Record<string, unknown>;
|
||
}
|
||
|
||
export interface PluginEnvironmentLease {
|
||
providerLeaseId: string | null;
|
||
metadata?: Record<string, unknown>;
|
||
expiresAt?: string | null;
|
||
}
|
||
|
||
/** Serializable provider result. The host adds refresh/close lifecycle methods. */
|
||
export interface PluginEnvironmentRunnerIngressEndpoint {
|
||
kind: "authenticated_websocket";
|
||
websocketUrl: string;
|
||
secretHeaders: Array<{ name: string; value: string }>;
|
||
generation: string;
|
||
}
|
||
|
||
export interface PluginEnvironmentRunnerIngressEndpointParams
|
||
extends PluginEnvironmentDriverBaseParams {
|
||
lease: PluginEnvironmentLease;
|
||
port: number;
|
||
path: string;
|
||
}
|
||
|
||
export interface PluginEnvironmentAcquireLeaseParams extends PluginEnvironmentDriverBaseParams {
|
||
runId: string;
|
||
workspaceMode?: string;
|
||
requestedCwd?: string;
|
||
agentId?: string;
|
||
executionWorkspaceId?: string | null;
|
||
/**
|
||
* The harness/adapter type for THIS run (the agent's adapter), so a single
|
||
* environment can serve mixed harnesses. When omitted, the driver falls back to
|
||
* the environment's configured default adapter. A provider that materializes a
|
||
* per-run sandbox should use this to select the runtime image and per-run env.
|
||
*/
|
||
adapterType?: string;
|
||
executionWorkspaceSettings?: Record<string, unknown> | null;
|
||
/**
|
||
* The absolute latest time the acquired lease may stay active, as an ISO 8601
|
||
* timestamp. A caller with an independent deadline (for example the setup-token
|
||
* login session) sets it. A provider that materializes a sandbox must configure
|
||
* a provider-side expiry at or before this time, and return the real provider
|
||
* expiry in `PluginEnvironmentLease.expiresAt`. When the provider cannot bound
|
||
* the sandbox at or before this time, it returns no expiry, so the server fails
|
||
* closed and releases the lease. When omitted, the provider keeps its default
|
||
* lifetime.
|
||
*/
|
||
requestedExpiresAt?: string | null;
|
||
}
|
||
|
||
export interface PluginEnvironmentResumeLeaseParams extends PluginEnvironmentDriverBaseParams {
|
||
providerLeaseId: string;
|
||
leaseMetadata?: Record<string, unknown>;
|
||
}
|
||
|
||
export interface PluginEnvironmentReleaseLeaseParams extends PluginEnvironmentDriverBaseParams {
|
||
providerLeaseId: string | null;
|
||
leaseMetadata?: Record<string, unknown>;
|
||
}
|
||
|
||
export interface PluginEnvironmentDestroyLeaseParams extends PluginEnvironmentReleaseLeaseParams {}
|
||
|
||
export interface PluginEnvironmentRealizeWorkspaceParams extends PluginEnvironmentDriverBaseParams {
|
||
lease: PluginEnvironmentLease;
|
||
workspace: {
|
||
localPath?: string;
|
||
remotePath?: string;
|
||
mode?: string;
|
||
metadata?: Record<string, unknown>;
|
||
};
|
||
}
|
||
|
||
/**
|
||
* A plugin `environmentRealizeWorkspace` handler returns only the realized cwd and provider
|
||
* metadata. The server, not the plugin, builds the full workspace-realization record from the run
|
||
* request and merges this cwd and metadata into it. Do not return a `workspaceRealization` record
|
||
* here; the server owns that record, so the referenced (mentioned) project sources reach the adapter.
|
||
*/
|
||
export interface PluginEnvironmentRealizeWorkspaceResult {
|
||
cwd: string;
|
||
metadata?: Record<string, unknown>;
|
||
}
|
||
|
||
export interface PluginEnvironmentExecuteParams extends PluginEnvironmentDriverBaseParams {
|
||
lease: PluginEnvironmentLease;
|
||
command: string;
|
||
args?: string[];
|
||
cwd?: string;
|
||
env?: Record<string, string>;
|
||
stdin?: string;
|
||
timeoutMs?: number;
|
||
/**
|
||
* Run this command outside the lease's persistent session.
|
||
*
|
||
* The host sets this flag on a command that runs before the run's agent work,
|
||
* for example the workspace provision command. A provider that opens a
|
||
* persistent session on the first command must NOT open the session for such a
|
||
* command; it runs the command one-shot and leaves the session closed. The
|
||
* session then opens on the first in-run command instead. A provider that does
|
||
* not use a persistent session ignores this flag.
|
||
*
|
||
* The default (absent or `false`) keeps the session path, so a normal in-run
|
||
* command opens and reuses the session as before.
|
||
*/
|
||
bypassSession?: boolean;
|
||
}
|
||
|
||
export interface PluginEnvironmentExecuteResult {
|
||
exitCode: number | null;
|
||
signal?: string | null;
|
||
timedOut: boolean;
|
||
stdout: string;
|
||
stderr: string;
|
||
metadata?: Record<string, unknown>;
|
||
}
|
||
|
||
/**
|
||
* A single source→target file or directory transfer within a sync operation.
|
||
*
|
||
* For `environmentSyncIn`, `sourcePath` is a host path and `targetPath` is a
|
||
* sandbox path; for `environmentSyncOut` the direction is reversed. All sandbox
|
||
* paths are POSIX. The contract is provider-agnostic: a provider may transfer a
|
||
* directory by whatever native mechanism it prefers (bulk upload, internal tar,
|
||
* per-file enumeration) as long as the observable result matches this mapping.
|
||
*/
|
||
export interface PluginSyncFileMapping {
|
||
/** Absolute path of the transfer source (host for syncIn, sandbox for syncOut). */
|
||
sourcePath: string;
|
||
/** Absolute path of the transfer target (sandbox for syncIn, host for syncOut). */
|
||
targetPath: string;
|
||
/** Whether the mapping transfers a single regular file or a directory tree. */
|
||
kind: "file" | "directory";
|
||
/**
|
||
* POSIX file mode to apply at the target (e.g. `0o600` for secret material).
|
||
* The target MUST carry this mode when the transfer completes.
|
||
*
|
||
* For a transfer to a host target, providers MUST apply the mode with no
|
||
* world-readable window: create the target with the mode, or apply the mode
|
||
* before the bytes arrive at the target path. A host file sits outside the
|
||
* sandbox boundary, so an open window shows the bytes to other host
|
||
* processes.
|
||
*
|
||
* For a transfer to a sandbox target, providers MAY apply the mode after
|
||
* they write the bytes. The sandbox is the trust boundary, so a short window
|
||
* shows the bytes only to code that already runs in that sandbox.
|
||
*/
|
||
mode?: number;
|
||
/** Glob patterns to exclude when `kind` is `"directory"`. */
|
||
exclude?: string[];
|
||
/**
|
||
* Symlink handling for `kind: "directory"` transfers. Falsy preserves symlinks
|
||
* as links; `true` dereferences them to their target bytes. Mirrors tar's `-h`.
|
||
*/
|
||
followSymlinks?: boolean;
|
||
/**
|
||
* Advisory read-write intent for the sandbox target. `"rw"` means the author
|
||
* expects the agent to change the bytes at the target and keep the change.
|
||
* `"ro"` means the target is a read-only tree. An absent value defaults to
|
||
* `"ro"` (read-only is the safe default for an advisory signal).
|
||
*
|
||
* This field is advisory metadata for an optional sandbox feedback wrapper. It
|
||
* does not change the transfer and adds no security. A provider may read it to
|
||
* bind the read-write targets read-write under the wrapper, but the ephemeral
|
||
* sandbox stays the only security boundary.
|
||
*/
|
||
access?: "rw" | "ro";
|
||
/**
|
||
* The sandbox directory that becomes read-write when `access` is `"rw"` and a
|
||
* post-upload command extracts `targetPath` into a different directory. A
|
||
* workspace, git-history, or asset mapping uploads a tar archive, so its
|
||
* `targetPath` is the staging archive under the runtime root, not the directory
|
||
* that the extract command fills. This field names that final destination
|
||
* directory, so a consumer records the real read-write destination, not the
|
||
* staging parent. When absent, the read-write destination is the parent
|
||
* directory of `targetPath`. This field is advisory and ignored when `access`
|
||
* is not `"rw"`.
|
||
*/
|
||
writablePath?: string;
|
||
}
|
||
|
||
/**
|
||
* A single control command run against the sandbox after a sync operation's
|
||
* files have landed. Ordered within {@link PluginSyncOperation.postUploadCommands}
|
||
* and executed in array order, fail-fast (the first non-zero exit or timeout
|
||
* aborts the operation).
|
||
*
|
||
* SECURITY — command origin (Stage-1 design review, condition C1). `command` is
|
||
* a **Paperclip/adapter-authored control operation**: it may be supplied ONLY by
|
||
* core/adapter code. No server route, issue/comment content, project/workspace
|
||
* file content, provider-plugin callback, or arbitrary adapter config may supply
|
||
* a raw `command` string, and any path embedded in it MUST be built by
|
||
* adapter/core helpers from already-confined paths and shell-quoted (C3). A
|
||
* provider MUST treat the command as **opaque**: it may execute or reject it, but
|
||
* MUST NOT rewrite, concatenate, or append provider-decided shell fragments to
|
||
* it.
|
||
*/
|
||
export interface PluginPostUploadCommand {
|
||
/**
|
||
* The opaque, adapter-authored shell command to run after upload. Executed
|
||
* verbatim by the provider (never rewritten/concatenated). See the security
|
||
* note above.
|
||
*/
|
||
command: string;
|
||
/**
|
||
* Working directory for the command. When present, MUST be an absolute POSIX
|
||
* path confined under the operation's allowed sandbox target root (condition
|
||
* C2); providers re-validate it before exec. When absent, the provider
|
||
* defaults to the resolved sync remote/runtime root — never a process default
|
||
* cwd.
|
||
*/
|
||
cwd?: string;
|
||
/** Optional per-command timeout in milliseconds. */
|
||
timeoutMs?: number;
|
||
}
|
||
|
||
/**
|
||
* An ordered, opaque unit of work handed to a sync hook. The `operationId` is an
|
||
* opaque, non-sensitive token authored by the orchestrator; a provider MUST NOT
|
||
* interpret it. Operations are applied in array order.
|
||
*/
|
||
export interface PluginSyncOperation {
|
||
operationId: string;
|
||
files: PluginSyncFileMapping[];
|
||
/**
|
||
* Optional ordered control commands run after this operation's files land, in
|
||
* array order, fail-fast. Absent means "no commands" — byte-identical to a
|
||
* pre-contract operation. See {@link PluginPostUploadCommand} for the command
|
||
* origin/confinement security contract (C1–C4).
|
||
*/
|
||
postUploadCommands?: PluginPostUploadCommand[];
|
||
}
|
||
|
||
export interface PluginEnvironmentSyncInParams extends PluginEnvironmentDriverBaseParams {
|
||
lease: PluginEnvironmentLease;
|
||
operations: PluginSyncOperation[];
|
||
}
|
||
|
||
export interface PluginEnvironmentSyncOutParams extends PluginEnvironmentDriverBaseParams {
|
||
lease: PluginEnvironmentLease;
|
||
operations: PluginSyncOperation[];
|
||
}
|
||
|
||
/** Per-operation transfer accounting returned by a sync hook, for observability. */
|
||
export interface PluginEnvironmentSyncResult {
|
||
operations: {
|
||
operationId: string;
|
||
filesTransferred: number;
|
||
bytesTransferred: number;
|
||
}[];
|
||
}
|
||
|
||
export type PluginEnvironmentInteractiveSetupStatus =
|
||
| "starting"
|
||
| "waiting_for_user"
|
||
| "capturing"
|
||
| "promoted"
|
||
| "cancelled"
|
||
| "timed_out"
|
||
| "failed"
|
||
| "missing";
|
||
|
||
export type PluginEnvironmentInteractiveSetupConnectionType =
|
||
| "ssh"
|
||
| (string & {});
|
||
|
||
export type PluginEnvironmentTemplateRefKind =
|
||
| "snapshot"
|
||
| "image"
|
||
| "provider_template"
|
||
| "unknown"
|
||
| (string & {});
|
||
|
||
export interface PluginEnvironmentInteractiveSetupConnectionSummary {
|
||
type: PluginEnvironmentInteractiveSetupConnectionType;
|
||
username?: string | null;
|
||
hostRedacted: boolean;
|
||
portRedacted: boolean;
|
||
commandRedacted?: boolean;
|
||
expiresAt?: string | null;
|
||
metadata?: Record<string, unknown>;
|
||
}
|
||
|
||
export interface PluginEnvironmentInteractiveSetupConnectionPayload {
|
||
type: PluginEnvironmentInteractiveSetupConnectionType;
|
||
command?: string | null;
|
||
token?: string | null;
|
||
expiresAt?: string | null;
|
||
metadata?: Record<string, unknown>;
|
||
}
|
||
|
||
export interface PluginEnvironmentInteractiveSetupSession {
|
||
providerLeaseId: string | null;
|
||
status: PluginEnvironmentInteractiveSetupStatus;
|
||
connectionSummary: PluginEnvironmentInteractiveSetupConnectionSummary | null;
|
||
connectionPayload?: PluginEnvironmentInteractiveSetupConnectionPayload | null;
|
||
expiresAt?: string | null;
|
||
metadata?: Record<string, unknown>;
|
||
}
|
||
|
||
export interface PluginEnvironmentStartInteractiveSetupParams extends PluginEnvironmentDriverBaseParams {
|
||
sessionId: string;
|
||
sourceTemplateRef?: string | null;
|
||
sourceTemplateKind?: PluginEnvironmentTemplateRefKind | null;
|
||
connectionExpiresInMinutes?: number | null;
|
||
expiresAt?: string | null;
|
||
}
|
||
|
||
export interface PluginEnvironmentGetInteractiveSetupParams extends PluginEnvironmentDriverBaseParams {
|
||
providerLeaseId: string | null;
|
||
setupMetadata?: Record<string, unknown>;
|
||
includeConnectionPayload?: boolean;
|
||
connectionExpiresInMinutes?: number | null;
|
||
}
|
||
|
||
export interface PluginEnvironmentCaptureTemplateParams extends PluginEnvironmentDriverBaseParams {
|
||
providerLeaseId: string | null;
|
||
setupMetadata?: Record<string, unknown>;
|
||
sourceTemplateRef?: string | null;
|
||
previousTemplateRef?: string | null;
|
||
templateLabel?: string | null;
|
||
timeoutMs?: number | null;
|
||
}
|
||
|
||
export interface PluginEnvironmentCaptureTemplateResult {
|
||
templateRef: string;
|
||
templateKind: PluginEnvironmentTemplateRefKind;
|
||
metadata?: Record<string, unknown>;
|
||
}
|
||
|
||
export interface PluginEnvironmentCancelInteractiveSetupParams extends PluginEnvironmentDriverBaseParams {
|
||
providerLeaseId: string | null;
|
||
setupMetadata?: Record<string, unknown>;
|
||
reason?: string | null;
|
||
}
|
||
|
||
export interface PluginEnvironmentCancelInteractiveSetupResult {
|
||
status: Extract<PluginEnvironmentInteractiveSetupStatus, "cancelled" | "timed_out" | "failed" | "missing">;
|
||
metadata?: Record<string, unknown>;
|
||
}
|
||
|
||
export interface PluginEnvironmentDeleteTemplateParams extends PluginEnvironmentDriverBaseParams {
|
||
templateRef: string;
|
||
templateKind?: PluginEnvironmentTemplateRefKind;
|
||
metadata?: Record<string, unknown>;
|
||
reason?: string | null;
|
||
}
|
||
|
||
export interface PluginEnvironmentDeleteTemplateResult {
|
||
deleted: boolean;
|
||
metadata?: Record<string, unknown>;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// UI launcher / modal host interaction payloads
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Bounds request issued by a plugin UI running inside a host-managed launcher
|
||
* container such as a modal, drawer, or popover.
|
||
*/
|
||
export interface PluginModalBoundsRequest {
|
||
/** High-level size preset requested from the host. */
|
||
bounds: PluginLauncherBounds;
|
||
/** Optional explicit width override in CSS pixels. */
|
||
width?: number;
|
||
/** Optional explicit height override in CSS pixels. */
|
||
height?: number;
|
||
/** Optional lower bounds for host resizing decisions. */
|
||
minWidth?: number;
|
||
minHeight?: number;
|
||
/** Optional upper bounds for host resizing decisions. */
|
||
maxWidth?: number;
|
||
maxHeight?: number;
|
||
}
|
||
|
||
/**
|
||
* Reason metadata supplied by host-managed close lifecycle callbacks.
|
||
*/
|
||
export interface PluginRenderCloseEvent {
|
||
reason:
|
||
| "escapeKey"
|
||
| "backdrop"
|
||
| "hostNavigation"
|
||
| "programmatic"
|
||
| "submit"
|
||
| "unknown";
|
||
nativeEvent?: unknown;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Login pseudo-terminal (PTY) worker methods.
|
||
// ---------------------------------------------------------------------------
|
||
// The host drives one live Claude `setup-token` login pseudo-terminal inside a
|
||
// sandbox provider worker. The host owns the route. It mints an opaque host
|
||
// route identifier, carries that identifier in the open request, and keys the
|
||
// close on that identifier. The worker registers the terminal under the host
|
||
// route identifier and returns a worker session identifier for the output
|
||
// notification binding only. The worker never keys a close on the worker
|
||
// session identifier, so the host closes a worker-created terminal even when the
|
||
// open reply was lost and no worker session identifier arrived. The worker sends
|
||
// output and exit as notifications, never as a reply, so the host binds them by
|
||
// the worker session identifier while the route is open.
|
||
|
||
/**
|
||
* The closed set of login command identities. The host resolves the key from the
|
||
* trusted adapter type and carries it in the open request. The worker maps the
|
||
* key to a compile-time command. The open request carries no command string, so a
|
||
* caller cannot select or override the command.
|
||
*/
|
||
export type PluginLoginCommandKey = "claude" | "codex" | "grok";
|
||
|
||
/** The open request for one live login pseudo-terminal. The worker registers the terminal by `hostRouteId`. */
|
||
export interface PluginLoginPtyOpenParams {
|
||
/** The host-owned opaque route identifier. The worker registers the terminal by it. */
|
||
hostRouteId: string;
|
||
/** The environment driver key, for the worker sandbox scope. It routes the worker; it confers no command authority. */
|
||
driverKey: string;
|
||
/** The company that owns the login session. */
|
||
companyId: string;
|
||
/** The environment the login session runs in. */
|
||
environmentId: string;
|
||
/** The provider lease the sandbox is cached under. The worker resolves the sandbox by it. */
|
||
providerLeaseId: string;
|
||
/**
|
||
* The host-resolved fixed command identity. The worker maps it to a
|
||
* compile-time command. The open request carries no command string.
|
||
*/
|
||
loginCommandKey: PluginLoginCommandKey;
|
||
/**
|
||
* The server-controlled, validated session home. The shape is exact:
|
||
* `/tmp/paperclip-adapter-login/<uuid>`. The worker revalidates the shape
|
||
* before it touches the filesystem.
|
||
*/
|
||
sessionHome: string;
|
||
}
|
||
|
||
/** The open reply. It returns the worker session identifier for output binding only. */
|
||
export interface PluginLoginPtyOpenResult {
|
||
/** The worker session identifier. It binds the output and the exit notification only. */
|
||
workerSessionId: string;
|
||
}
|
||
|
||
/** The input request. It carries the worker session identifier and the raw input bytes. */
|
||
export interface PluginLoginPtyInputParams {
|
||
/** The worker session identifier that the open reply returned. */
|
||
workerSessionId: string;
|
||
/** The raw input bytes to write to the terminal. */
|
||
data: string;
|
||
}
|
||
|
||
/** The stop request. It carries the worker session identifier. */
|
||
export interface PluginLoginPtyStopParams {
|
||
/** The worker session identifier that the open reply returned. */
|
||
workerSessionId: string;
|
||
}
|
||
|
||
/** The close request. The host route identifier is the authoritative key. */
|
||
export interface PluginLoginPtyCloseParams {
|
||
/**
|
||
* The host-owned opaque route identifier. This is the authoritative close key,
|
||
* so the host closes the terminal even when no worker session identifier
|
||
* arrived after a lost open reply.
|
||
*/
|
||
hostRouteId: string;
|
||
/**
|
||
* A non-authoritative worker session identifier. The worker never keys the
|
||
* close on it. The field is optional, so a close with only the host route
|
||
* identifier is a valid request for this lifecycle.
|
||
*/
|
||
workerSessionId?: string;
|
||
}
|
||
|
||
/** The close reply. It acknowledges the close and carries the same host route identifier. */
|
||
export interface PluginLoginPtyCloseResult {
|
||
/** The close acknowledgement. It carries the same host route identifier the close sent. */
|
||
hostRouteId: string;
|
||
}
|
||
|
||
/** The worker→host pseudo-terminal output notification parameters. Modeled on `execute.log`. */
|
||
export interface PluginLoginPtyOutputParams {
|
||
/** The worker session identifier that the open reply returned. */
|
||
workerSessionId: string;
|
||
/** The raw terminal output bytes. */
|
||
chunk: string;
|
||
}
|
||
|
||
/** The worker→host pseudo-terminal exit notification parameters. */
|
||
export interface PluginLoginPtyExitParams {
|
||
/** The worker session identifier that the open reply returned. */
|
||
workerSessionId: string;
|
||
/** The child exit code, or null when the child ended with no code. */
|
||
exitCode: number | null;
|
||
}
|
||
|
||
/**
|
||
* One live login pseudo-terminal session in the worker. The worker opener returns
|
||
* it. The shape matches the sandbox provider login pseudo-terminal session,
|
||
* so a provider passes its session with no adapter.
|
||
*/
|
||
export interface PluginLoginPtyWorkerSession {
|
||
/** Registers the one output listener. The session streams each raw chunk in order. */
|
||
onData(listener: (chunk: string) => void): void;
|
||
/** Writes raw input bytes to the pseudo-terminal. */
|
||
write(data: string): void;
|
||
/** Resolves with the child exit code when the command ends. */
|
||
wait(): Promise<{ exitCode: number | null }>;
|
||
/** Stops the child process. Safe to call more than one time. */
|
||
kill(): void;
|
||
/** Releases the session resources. Safe to call more than one time. */
|
||
close(): Promise<void>;
|
||
}
|
||
|
||
/** The worker→host notification method for one pseudo-terminal output chunk. */
|
||
export const LOGIN_PTY_OUTPUT_NOTIFICATION = "loginPty.output";
|
||
/** The worker→host notification method for one pseudo-terminal exit. */
|
||
export const LOGIN_PTY_EXIT_NOTIFICATION = "loginPty.exit";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Byte-safe duplex channel wire representation.
|
||
// ---------------------------------------------------------------------------
|
||
// A JSON-RPC message travels as one line of JSON text (see `serializeMessage`
|
||
// below). JSON has no binary type, so a raw byte chunk cannot cross this hop
|
||
// unchanged. `ChannelBytesWireValue` is the one JSON-safe encoding this
|
||
// protocol uses for a duplex channel chunk: a base64 string.
|
||
//
|
||
// Every layer above this hop carries the chunk as `Uint8Array`. This includes
|
||
// the plugin context, the worker RPC host's public duplex methods, and the
|
||
// host-side plugin worker manager. Only the JSON-RPC message itself holds the
|
||
// base64 form, and only for the one hop between the host process and the
|
||
// worker process.
|
||
//
|
||
// This base64 form is not the sandbox provider channel's wire format. That
|
||
// channel carries raw bytes with no base64 armor: a live measurement of the
|
||
// provider transport proved that every byte value survives it unchanged.
|
||
//
|
||
// HTTP/2 is the preferred transport. `queue_v1` is the soft-deprecated fallback.
|
||
|
||
/** The wire-safe JSON-RPC form of one duplex channel byte chunk: a base64 string. */
|
||
export type ChannelBytesWireValue = string;
|
||
|
||
/** Encodes raw channel bytes into the wire-safe JSON-RPC representation. */
|
||
export function encodeChannelBytes(bytes: Uint8Array): ChannelBytesWireValue {
|
||
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
|
||
}
|
||
|
||
/**
|
||
* Decodes the wire-safe JSON-RPC representation back to raw channel bytes.
|
||
* Returns `null` for a value that is not a well-formed base64 string, so a
|
||
* caller on the trust boundary treats a malformed frame as a protocol error
|
||
* instead of silently substituting the empty byte array.
|
||
*/
|
||
export function decodeChannelBytes(value: unknown): Uint8Array | null {
|
||
if (typeof value !== "string" || value.length === 0) return null;
|
||
// `Buffer.from(str, "base64")` silently drops an invalid character instead
|
||
// of throwing, so re-encode the decoded bytes and compare. A well-formed
|
||
// base64 string round-trips to itself; a malformed one does not.
|
||
const decoded = Buffer.from(value, "base64");
|
||
if (decoded.toString("base64") !== value) return null;
|
||
return new Uint8Array(decoded.buffer, decoded.byteOffset, decoded.byteLength);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Generic duplex channel worker methods.
|
||
// ---------------------------------------------------------------------------
|
||
// The host drives one persistent duplex channel inside a sandbox provider
|
||
// worker. The channel replaces the file transport of the sandbox callback bridge
|
||
// with one live bidirectional stream. These messages are generic. They model the
|
||
// login pseudo-terminal contract above, but they carry no login command
|
||
// allowlist. The host owns the route. It mints an opaque host route identifier,
|
||
// carries that identifier in the open request, and keys the close on that
|
||
// identifier. The worker registers the channel under the host route identifier
|
||
// and returns a worker session identifier for the data and the exit notification
|
||
// binding only. The worker never keys a close on the worker session identifier,
|
||
// so the host closes a worker-created channel even when the open reply was lost
|
||
// and no worker session identifier arrived. The worker sends data and exit as
|
||
// notifications, never as a reply, so the host binds them by the worker session
|
||
// identifier while the route is open.
|
||
|
||
/** The open request for one persistent duplex channel. The worker registers the channel by `hostRouteId`. */
|
||
export interface PluginDuplexChannelOpenParams {
|
||
/** The host-owned opaque route identifier. The worker registers the channel by it. */
|
||
hostRouteId: string;
|
||
/** The environment driver key, for the worker sandbox scope. */
|
||
driverKey: string;
|
||
/** The company that owns the channel. */
|
||
companyId: string;
|
||
/** The environment the channel runs in. */
|
||
environmentId: string;
|
||
/** The provider lease the sandbox is cached under. The worker resolves the sandbox by it. */
|
||
providerLeaseId: string;
|
||
/**
|
||
* The command argument vector the worker runs on the channel. Element 0 is the
|
||
* program and the rest are its arguments. The worker quotes each element for the
|
||
* shell, so a shell metacharacter in an element cannot inject a shell command.
|
||
*/
|
||
command: readonly string[];
|
||
}
|
||
|
||
/** The open reply. It echoes the host route identifier and returns the worker session identifier. */
|
||
export interface PluginDuplexChannelOpenResult {
|
||
/** The host route identifier the open request carried. The worker echoes it, so the host binds the exact pair. */
|
||
hostRouteId: string;
|
||
/** The worker session identifier. It binds the data and the exit notification only. */
|
||
workerSessionId: string;
|
||
}
|
||
|
||
/** The write request. It carries the exact route pair and the raw input bytes. */
|
||
export interface PluginDuplexChannelWriteParams {
|
||
/** The host route identifier the open request carried. The worker acts only on the exact live pair. */
|
||
hostRouteId: string;
|
||
/** The worker session identifier that the open reply returned. */
|
||
workerSessionId: string;
|
||
/** The raw input bytes to write to the channel, in the {@link ChannelBytesWireValue} wire form. */
|
||
data: ChannelBytesWireValue;
|
||
}
|
||
|
||
/** The stop request. It carries the exact route pair. */
|
||
export interface PluginDuplexChannelStopParams {
|
||
/** The host route identifier the open request carried. The worker acts only on the exact live pair. */
|
||
hostRouteId: string;
|
||
/** The worker session identifier that the open reply returned. */
|
||
workerSessionId: string;
|
||
}
|
||
|
||
/** The close request. The host route identifier is the authoritative key. */
|
||
export interface PluginDuplexChannelCloseParams {
|
||
/**
|
||
* The host-owned opaque route identifier. This is the authoritative close key,
|
||
* so the host closes the channel even when no worker session identifier arrived
|
||
* after a lost open reply.
|
||
*/
|
||
hostRouteId: string;
|
||
/**
|
||
* A non-authoritative worker session identifier. The worker never keys the
|
||
* close on it. The field is optional, so a close with only the host route
|
||
* identifier is a valid request for this lifecycle.
|
||
*/
|
||
workerSessionId?: string;
|
||
}
|
||
|
||
/** The close reply. It acknowledges the close and echoes the route identifiers. */
|
||
export interface PluginDuplexChannelCloseResult {
|
||
/** The close acknowledgement. It carries the same host route identifier the close sent. */
|
||
hostRouteId: string;
|
||
/**
|
||
* The bound worker session identifier. The worker echoes it on a bound close,
|
||
* so the host verifies the exact pair. It is absent on a pre-bind route-only
|
||
* close, where no session bound yet.
|
||
*/
|
||
workerSessionId?: string;
|
||
}
|
||
|
||
/** The worker→host duplex channel data notification parameters. */
|
||
export interface PluginDuplexChannelDataParams {
|
||
/** The host route identifier the open request carried. The worker echoes it, so the host routes the exact pair. */
|
||
hostRouteId: string;
|
||
/** The worker session identifier that the open reply returned. */
|
||
workerSessionId: string;
|
||
/** The raw channel output bytes, in the {@link ChannelBytesWireValue} wire form. */
|
||
chunk: ChannelBytesWireValue;
|
||
}
|
||
|
||
/** The worker→host duplex channel exit notification parameters. */
|
||
export interface PluginDuplexChannelExitParams {
|
||
/** The host route identifier the open request carried. The worker echoes it, so the host routes the exact pair. */
|
||
hostRouteId: string;
|
||
/** The worker session identifier that the open reply returned. */
|
||
workerSessionId: string;
|
||
/** The child exit code, or null when the child ended with no code. */
|
||
exitCode: number | null;
|
||
/**
|
||
* True when the provider transport closed with no exit data, so the exit is a
|
||
* reason-less transport close, not a process exit. Absent or false marks a real
|
||
* process exit. The host maps a transport close to a distinct loss reason.
|
||
*/
|
||
transportClosed?: boolean;
|
||
}
|
||
|
||
/** The worker→host notification method for one duplex channel data chunk. */
|
||
export const DUPLEX_CHANNEL_DATA_NOTIFICATION = "duplexChannel.data";
|
||
/** The worker→host notification method for one duplex channel exit. */
|
||
export const DUPLEX_CHANNEL_EXIT_NOTIFICATION = "duplexChannel.exit";
|
||
|
||
/**
|
||
* Map of host→worker RPC method names to their `[params, result]` types.
|
||
*
|
||
* This type is the single source of truth for all methods the host can call
|
||
* on a worker. Used by both the host dispatcher and the worker handler to
|
||
* ensure type safety across the IPC boundary.
|
||
*/
|
||
export interface HostToWorkerMethods {
|
||
/** @see PLUGIN_SPEC.md §13.1 */
|
||
initialize: [params: InitializeParams, result: InitializeResult];
|
||
/** @see PLUGIN_SPEC.md §13.2 */
|
||
health: [params: Record<string, never>, result: PluginHealthDiagnostics];
|
||
/** @see PLUGIN_SPEC.md §12.5 */
|
||
shutdown: [params: Record<string, never>, result: void];
|
||
/** @see PLUGIN_SPEC.md §13.3 */
|
||
validateConfig: [params: ValidateConfigParams, result: PluginConfigValidationResult];
|
||
/** @see PLUGIN_SPEC.md §13.4 */
|
||
configChanged: [params: ConfigChangedParams, result: void];
|
||
/** @see PLUGIN_SPEC.md §13.5 */
|
||
onEvent: [params: OnEventParams, result: void];
|
||
/** @see PLUGIN_SPEC.md §13.6 */
|
||
runJob: [params: RunJobParams, result: void];
|
||
/** @see PLUGIN_SPEC.md §13.7 */
|
||
handleWebhook: [params: PluginWebhookInput, result: void];
|
||
/** Scoped plugin API route dispatch. */
|
||
handleApiRequest: [params: PluginApiRequestInput, result: PluginApiResponse];
|
||
/** @see PLUGIN_SPEC.md §13.8 */
|
||
getData: [params: GetDataParams, result: unknown];
|
||
/** @see PLUGIN_SPEC.md §13.9 */
|
||
performAction: [params: PerformActionParams, result: unknown];
|
||
/** @see PLUGIN_SPEC.md §13.10 */
|
||
executeTool: [params: ExecuteToolParams, result: ToolResult];
|
||
detectExternalObjects: [
|
||
params: DetectExternalObjectsParams,
|
||
result: DetectExternalObjectsResult,
|
||
];
|
||
resolveExternalObject: [
|
||
params: ResolveExternalObjectParams,
|
||
result: PluginExternalObjectResolveResult,
|
||
];
|
||
refreshExternalObjects: [
|
||
params: RefreshExternalObjectsParams,
|
||
result: RefreshExternalObjectsResult,
|
||
];
|
||
environmentValidateConfig: [
|
||
params: PluginEnvironmentValidateConfigParams,
|
||
result: PluginEnvironmentValidationResult,
|
||
];
|
||
environmentProbe: [
|
||
params: PluginEnvironmentProbeParams,
|
||
result: PluginEnvironmentProbeResult,
|
||
];
|
||
environmentAcquireLease: [
|
||
params: PluginEnvironmentAcquireLeaseParams,
|
||
result: PluginEnvironmentLease,
|
||
];
|
||
environmentResumeLease: [
|
||
params: PluginEnvironmentResumeLeaseParams,
|
||
result: PluginEnvironmentLease,
|
||
];
|
||
environmentReleaseLease: [
|
||
params: PluginEnvironmentReleaseLeaseParams,
|
||
result: void,
|
||
];
|
||
environmentDestroyLease: [
|
||
params: PluginEnvironmentDestroyLeaseParams,
|
||
result: void,
|
||
];
|
||
environmentRealizeWorkspace: [
|
||
params: PluginEnvironmentRealizeWorkspaceParams,
|
||
result: PluginEnvironmentRealizeWorkspaceResult,
|
||
];
|
||
environmentExecute: [
|
||
params: PluginEnvironmentExecuteParams,
|
||
result: PluginEnvironmentExecuteResult,
|
||
];
|
||
environmentRunnerIngressEndpoint: [
|
||
params: PluginEnvironmentRunnerIngressEndpointParams,
|
||
result: PluginEnvironmentRunnerIngressEndpoint,
|
||
];
|
||
environmentSyncIn: [
|
||
params: PluginEnvironmentSyncInParams,
|
||
result: PluginEnvironmentSyncResult,
|
||
];
|
||
environmentSyncOut: [
|
||
params: PluginEnvironmentSyncOutParams,
|
||
result: PluginEnvironmentSyncResult,
|
||
];
|
||
environmentStartInteractiveSetup: [
|
||
params: PluginEnvironmentStartInteractiveSetupParams,
|
||
result: PluginEnvironmentInteractiveSetupSession,
|
||
];
|
||
environmentGetInteractiveSetup: [
|
||
params: PluginEnvironmentGetInteractiveSetupParams,
|
||
result: PluginEnvironmentInteractiveSetupSession,
|
||
];
|
||
environmentCaptureTemplate: [
|
||
params: PluginEnvironmentCaptureTemplateParams,
|
||
result: PluginEnvironmentCaptureTemplateResult,
|
||
];
|
||
environmentCancelInteractiveSetup: [
|
||
params: PluginEnvironmentCancelInteractiveSetupParams,
|
||
result: PluginEnvironmentCancelInteractiveSetupResult,
|
||
];
|
||
environmentDeleteTemplate: [
|
||
params: PluginEnvironmentDeleteTemplateParams,
|
||
result: PluginEnvironmentDeleteTemplateResult,
|
||
];
|
||
/** Open one live login pseudo-terminal keyed by a host-owned route identifier. */
|
||
loginPtyOpen: [
|
||
params: PluginLoginPtyOpenParams,
|
||
result: PluginLoginPtyOpenResult,
|
||
];
|
||
/** Write delayed input to a live login pseudo-terminal, keyed by the worker session identifier. */
|
||
loginPtyInput: [params: PluginLoginPtyInputParams, result: void];
|
||
/** Stop a live login pseudo-terminal child, keyed by the worker session identifier. */
|
||
loginPtyStop: [params: PluginLoginPtyStopParams, result: void];
|
||
/** Close a live login pseudo-terminal by the host route identifier and return a bound acknowledgement. */
|
||
loginPtyClose: [
|
||
params: PluginLoginPtyCloseParams,
|
||
result: PluginLoginPtyCloseResult,
|
||
];
|
||
/** Open one persistent duplex channel keyed by a host-owned route identifier. */
|
||
duplexChannelOpen: [
|
||
params: PluginDuplexChannelOpenParams,
|
||
result: PluginDuplexChannelOpenResult,
|
||
];
|
||
/** Write raw input to a persistent duplex channel, keyed by the worker session identifier. */
|
||
duplexChannelWrite: [params: PluginDuplexChannelWriteParams, result: void];
|
||
/** Stop a persistent duplex channel child, keyed by the worker session identifier. */
|
||
duplexChannelStop: [params: PluginDuplexChannelStopParams, result: void];
|
||
/** Close a persistent duplex channel by the host route identifier and return a bound acknowledgement. */
|
||
duplexChannelClose: [
|
||
params: PluginDuplexChannelCloseParams,
|
||
result: PluginDuplexChannelCloseResult,
|
||
];
|
||
}
|
||
|
||
/** Union of all host→worker method names. */
|
||
export type HostToWorkerMethodName = keyof HostToWorkerMethods;
|
||
|
||
/** Required methods the worker MUST implement. */
|
||
export const HOST_TO_WORKER_REQUIRED_METHODS: readonly HostToWorkerMethodName[] = [
|
||
"initialize",
|
||
"health",
|
||
"shutdown",
|
||
] as const;
|
||
|
||
/** Optional methods the worker MAY implement. */
|
||
export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[] = [
|
||
"validateConfig",
|
||
"configChanged",
|
||
"onEvent",
|
||
"runJob",
|
||
"handleWebhook",
|
||
"handleApiRequest",
|
||
"getData",
|
||
"performAction",
|
||
"executeTool",
|
||
"detectExternalObjects",
|
||
"resolveExternalObject",
|
||
"refreshExternalObjects",
|
||
"environmentValidateConfig",
|
||
"environmentProbe",
|
||
"environmentAcquireLease",
|
||
"environmentResumeLease",
|
||
"environmentReleaseLease",
|
||
"environmentDestroyLease",
|
||
"environmentRealizeWorkspace",
|
||
"environmentExecute",
|
||
"environmentRunnerIngressEndpoint",
|
||
"environmentSyncIn",
|
||
"environmentSyncOut",
|
||
"environmentStartInteractiveSetup",
|
||
"environmentGetInteractiveSetup",
|
||
"environmentCaptureTemplate",
|
||
"environmentCancelInteractiveSetup",
|
||
"environmentDeleteTemplate",
|
||
"loginPtyOpen",
|
||
"loginPtyInput",
|
||
"loginPtyStop",
|
||
"loginPtyClose",
|
||
"duplexChannelOpen",
|
||
"duplexChannelWrite",
|
||
"duplexChannelStop",
|
||
"duplexChannelClose",
|
||
] as const;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Worker → Host Method Signatures (SDK client calls)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Map of worker→host RPC method names to their `[params, result]` types.
|
||
*
|
||
* These represent the SDK client calls that the worker makes back to the
|
||
* host to access platform services (state, entities, config, etc.).
|
||
*/
|
||
export interface WorkerToHostMethods {
|
||
// Config
|
||
"config.get": [params: { companyId?: string }, result: Record<string, unknown>];
|
||
|
||
// Trusted local folders
|
||
"localFolders.declarations": [
|
||
params: Record<string, never>,
|
||
result: PluginLocalFolderDeclaration[],
|
||
];
|
||
"localFolders.configure": [
|
||
params: {
|
||
companyId: string;
|
||
folderKey: string;
|
||
path: string;
|
||
access?: "read" | "readWrite";
|
||
requiredDirectories?: string[];
|
||
requiredFiles?: string[];
|
||
},
|
||
result: PluginLocalFolderStatus,
|
||
];
|
||
"localFolders.status": [
|
||
params: { companyId: string; folderKey: string },
|
||
result: PluginLocalFolderStatus,
|
||
];
|
||
"localFolders.list": [
|
||
params: { companyId: string; folderKey: string; relativePath?: string | null; recursive?: boolean; maxEntries?: number },
|
||
result: PluginLocalFolderListing,
|
||
];
|
||
"localFolders.readText": [
|
||
params: { companyId: string; folderKey: string; relativePath: string },
|
||
result: string,
|
||
];
|
||
"localFolders.writeTextAtomic": [
|
||
params: {
|
||
companyId: string;
|
||
folderKey: string;
|
||
relativePath: string;
|
||
contents: string;
|
||
},
|
||
result: PluginLocalFolderStatus,
|
||
];
|
||
"localFolders.deleteFile": [
|
||
params: { companyId: string; folderKey: string; relativePath: string },
|
||
result: PluginLocalFolderStatus,
|
||
];
|
||
|
||
// State
|
||
"state.get": [
|
||
params: { scopeKind: string; scopeId?: string; namespace?: string; stateKey: string },
|
||
result: unknown,
|
||
];
|
||
"state.set": [
|
||
params: { scopeKind: string; scopeId?: string; namespace?: string; stateKey: string; value: unknown },
|
||
result: void,
|
||
];
|
||
"state.delete": [
|
||
params: { scopeKind: string; scopeId?: string; namespace?: string; stateKey: string },
|
||
result: void,
|
||
];
|
||
|
||
// Restricted plugin database namespace
|
||
"db.namespace": [
|
||
params: Record<string, never>,
|
||
result: string,
|
||
];
|
||
"db.query": [
|
||
params: { sql: string; params?: unknown[] },
|
||
result: unknown[],
|
||
];
|
||
"db.execute": [
|
||
params: { sql: string; params?: unknown[] },
|
||
result: { rowCount: number },
|
||
];
|
||
|
||
// Entities
|
||
"entities.upsert": [
|
||
params: {
|
||
entityType: string;
|
||
scopeKind: PluginStateScopeKind;
|
||
scopeId?: string;
|
||
externalId?: string;
|
||
title?: string;
|
||
status?: string;
|
||
data: Record<string, unknown>;
|
||
},
|
||
result: {
|
||
id: string;
|
||
entityType: string;
|
||
scopeKind: PluginStateScopeKind;
|
||
scopeId: string | null;
|
||
externalId: string | null;
|
||
title: string | null;
|
||
status: string | null;
|
||
data: Record<string, unknown>;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
},
|
||
];
|
||
"entities.list": [
|
||
params: {
|
||
entityType?: string;
|
||
scopeKind?: PluginStateScopeKind;
|
||
scopeId?: string;
|
||
externalId?: string;
|
||
limit?: number;
|
||
offset?: number;
|
||
},
|
||
result: Array<{
|
||
id: string;
|
||
entityType: string;
|
||
scopeKind: PluginStateScopeKind;
|
||
scopeId: string | null;
|
||
externalId: string | null;
|
||
title: string | null;
|
||
status: string | null;
|
||
data: Record<string, unknown>;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
}>,
|
||
];
|
||
|
||
// Events
|
||
"events.emit": [
|
||
params: { name: string; companyId: string; payload: unknown },
|
||
result: void,
|
||
];
|
||
"events.subscribe": [
|
||
params: { eventPattern: string; filter?: Record<string, unknown> | null },
|
||
result: void,
|
||
];
|
||
|
||
// HTTP
|
||
"http.fetch": [
|
||
params: { url: string; init?: Record<string, unknown> },
|
||
result: { status: number; statusText: string; headers: Record<string, string>; body: string },
|
||
];
|
||
|
||
// Secrets
|
||
"secrets.resolve": [
|
||
params: { secretRef: string | EnvSecretRefBinding; companyId?: string; configPath?: string },
|
||
result: string,
|
||
];
|
||
|
||
// Activity
|
||
"activity.log": [
|
||
params: {
|
||
companyId: string;
|
||
message: string;
|
||
entityType?: string;
|
||
entityId?: string;
|
||
metadata?: Record<string, unknown>;
|
||
},
|
||
result: void,
|
||
];
|
||
|
||
// Metrics
|
||
"metrics.write": [
|
||
params: {
|
||
name: string;
|
||
value: number;
|
||
tags?: Record<string, string>;
|
||
/** Owning tenant for `plugin_logs.company_id` (cascade-delete scope). `null`/omitted = instance-scope. */
|
||
companyId?: string | null;
|
||
},
|
||
result: void,
|
||
];
|
||
|
||
// Telemetry
|
||
"telemetry.track": [
|
||
params: { eventName: string; dimensions?: Record<string, string | number | boolean> },
|
||
result: void,
|
||
];
|
||
|
||
// Logger
|
||
"log": [
|
||
params: {
|
||
level: "info" | "warn" | "error" | "debug";
|
||
message: string;
|
||
meta?: Record<string, unknown>;
|
||
/** Owning tenant for `plugin_logs.company_id` (cascade-delete scope). `null`/omitted = instance-scope. */
|
||
companyId?: string | null;
|
||
},
|
||
result: void,
|
||
];
|
||
|
||
// Provider span sink. The worker sends a finished provider span; the host
|
||
// re-clamps the label and the attributes at its trust boundary, mints the
|
||
// parentage from its own invocation record, and records the span through the
|
||
// real tracer. The worker never sends the parent `traceparent`; the host
|
||
// recovers it from the echoed invocation id. The RPC is capability-gated.
|
||
"span.record": [
|
||
params: {
|
||
/** The bounded span name (for example `pack` or `transfer`). The host
|
||
* clamps it to a closed set, so a name never carries free-form data. */
|
||
name: string;
|
||
/** The span attributes. The host drops every key that is not on the closed
|
||
* plugin-span allowlist and re-clamps each remaining value. */
|
||
attributes?: Record<string, string | number | boolean>;
|
||
/** The optional span status. */
|
||
status?: { code: number; message?: string };
|
||
/** The optional span start time as epoch milliseconds (`Date.now()`).
|
||
* The worker captures it when it opens the span. The host validates the
|
||
* pair and records the span with its true native width. An omitted value
|
||
* makes the host fall back to a synchronous open-and-end. */
|
||
startTimeMs?: number;
|
||
/** The optional span end time as epoch milliseconds (`Date.now()`). The
|
||
* worker captures it when it ends the span. The host uses it as the span
|
||
* end time when the pair passes the clock-safety check. */
|
||
endTimeMs?: number;
|
||
},
|
||
result: void,
|
||
];
|
||
|
||
// Companies (read)
|
||
"companies.list": [
|
||
params: { limit?: number; offset?: number },
|
||
result: Company[],
|
||
];
|
||
"companies.get": [
|
||
params: { companyId: string },
|
||
result: Company | null,
|
||
];
|
||
|
||
// Projects (read)
|
||
"projects.list": [
|
||
params: { companyId: string; limit?: number; offset?: number },
|
||
result: Project[],
|
||
];
|
||
"projects.get": [
|
||
params: { projectId: string; companyId: string },
|
||
result: Project | null,
|
||
];
|
||
"projects.listWorkspaces": [
|
||
params: { projectId: string; companyId: string },
|
||
result: PluginWorkspace[],
|
||
];
|
||
"projects.getPrimaryWorkspace": [
|
||
params: { projectId: string; companyId: string },
|
||
result: PluginWorkspace | null,
|
||
];
|
||
"projects.getWorkspaceForIssue": [
|
||
params: { issueId: string; companyId: string },
|
||
result: PluginWorkspace | null,
|
||
];
|
||
"executionWorkspaces.get": [
|
||
params: {
|
||
workspaceId: string;
|
||
companyId: string;
|
||
},
|
||
result: PluginExecutionWorkspaceMetadata | null,
|
||
];
|
||
"projects.managed.get": [
|
||
params: { projectKey: string; companyId: string },
|
||
result: PluginManagedProjectResolution,
|
||
];
|
||
"projects.managed.reconcile": [
|
||
params: { projectKey: string; companyId: string },
|
||
result: PluginManagedProjectResolution,
|
||
];
|
||
"projects.managed.reset": [
|
||
params: { projectKey: string; companyId: string },
|
||
result: PluginManagedProjectResolution,
|
||
];
|
||
"routines.managed.get": [
|
||
params: { routineKey: string; companyId: string },
|
||
result: PluginManagedRoutineResolution,
|
||
];
|
||
"routines.managed.reconcile": [
|
||
params: {
|
||
routineKey: string;
|
||
companyId: string;
|
||
assigneeAgentId?: string | null;
|
||
projectId?: string | null;
|
||
},
|
||
result: PluginManagedRoutineResolution,
|
||
];
|
||
"routines.managed.reset": [
|
||
params: {
|
||
routineKey: string;
|
||
companyId: string;
|
||
assigneeAgentId?: string | null;
|
||
projectId?: string | null;
|
||
},
|
||
result: PluginManagedRoutineResolution,
|
||
];
|
||
"routines.managed.update": [
|
||
params: {
|
||
routineKey: string;
|
||
companyId: string;
|
||
status?: string;
|
||
},
|
||
result: Routine,
|
||
];
|
||
"routines.managed.run": [
|
||
params: {
|
||
routineKey: string;
|
||
companyId: string;
|
||
assigneeAgentId?: string | null;
|
||
projectId?: string | null;
|
||
},
|
||
result: RoutineRun,
|
||
];
|
||
"skills.managed.get": [
|
||
params: { skillKey: string; companyId: string },
|
||
result: PluginManagedSkillResolution,
|
||
];
|
||
"skills.managed.reconcile": [
|
||
params: { skillKey: string; companyId: string },
|
||
result: PluginManagedSkillResolution,
|
||
];
|
||
"skills.managed.reset": [
|
||
params: { skillKey: string; companyId: string },
|
||
result: PluginManagedSkillResolution,
|
||
];
|
||
|
||
// Issues
|
||
"issues.list": [
|
||
params: {
|
||
companyId: string;
|
||
projectId?: string;
|
||
assigneeAgentId?: string;
|
||
originKind?: string;
|
||
originKindPrefix?: string;
|
||
originId?: string;
|
||
status?: string;
|
||
includePluginOperations?: boolean;
|
||
limit?: number;
|
||
offset?: number;
|
||
},
|
||
result: Issue[],
|
||
];
|
||
"issues.get": [
|
||
params: { issueId: string; companyId: string },
|
||
result: Issue | null,
|
||
];
|
||
"issues.create": [
|
||
params: {
|
||
companyId: string;
|
||
projectId?: string;
|
||
goalId?: string;
|
||
parentId?: string;
|
||
inheritExecutionWorkspaceFromIssueId?: string;
|
||
title: string;
|
||
description?: string;
|
||
status?: string;
|
||
priority?: string;
|
||
assigneeAgentId?: string;
|
||
assigneeUserId?: string | null;
|
||
requestDepth?: number;
|
||
billingCode?: string | null;
|
||
assigneeAdapterOverrides?: IssueAssigneeAdapterOverrides | null;
|
||
surfaceVisibility?: string | null;
|
||
originKind?: string | null;
|
||
originId?: string | null;
|
||
originRunId?: string | null;
|
||
blockedByIssueIds?: string[];
|
||
labelIds?: string[];
|
||
executionWorkspaceId?: string | null;
|
||
executionWorkspacePreference?: string | null;
|
||
executionWorkspaceSettings?: Record<string, unknown> | null;
|
||
actorAgentId?: string | null;
|
||
actorUserId?: string | null;
|
||
actorRunId?: string | null;
|
||
},
|
||
result: Issue,
|
||
];
|
||
"issues.update": [
|
||
params: {
|
||
issueId: string;
|
||
patch: Record<string, unknown>;
|
||
companyId: string;
|
||
},
|
||
result: Issue,
|
||
];
|
||
"issues.relations.get": [
|
||
params: { issueId: string; companyId: string },
|
||
result: PluginIssueRelationSummary,
|
||
];
|
||
"issues.relations.setBlockedBy": [
|
||
params: {
|
||
issueId: string;
|
||
companyId: string;
|
||
blockedByIssueIds: string[];
|
||
actorAgentId?: string | null;
|
||
actorUserId?: string | null;
|
||
actorRunId?: string | null;
|
||
},
|
||
result: PluginIssueRelationSummary,
|
||
];
|
||
"issues.relations.addBlockers": [
|
||
params: {
|
||
issueId: string;
|
||
companyId: string;
|
||
blockerIssueIds: string[];
|
||
actorAgentId?: string | null;
|
||
actorUserId?: string | null;
|
||
actorRunId?: string | null;
|
||
},
|
||
result: PluginIssueRelationSummary,
|
||
];
|
||
"issues.relations.removeBlockers": [
|
||
params: {
|
||
issueId: string;
|
||
companyId: string;
|
||
blockerIssueIds: string[];
|
||
actorAgentId?: string | null;
|
||
actorUserId?: string | null;
|
||
actorRunId?: string | null;
|
||
},
|
||
result: PluginIssueRelationSummary,
|
||
];
|
||
"issues.assertCheckoutOwner": [
|
||
params: {
|
||
issueId: string;
|
||
companyId: string;
|
||
actorAgentId: string;
|
||
actorRunId: string;
|
||
},
|
||
result: PluginIssueCheckoutOwnership,
|
||
];
|
||
"issues.getSubtree": [
|
||
params: {
|
||
issueId: string;
|
||
companyId: string;
|
||
includeRoot?: boolean;
|
||
includeRelations?: boolean;
|
||
includeDocuments?: boolean;
|
||
includeActiveRuns?: boolean;
|
||
includeAssignees?: boolean;
|
||
},
|
||
result: PluginIssueSubtree,
|
||
];
|
||
"issues.requestWakeup": [
|
||
params: {
|
||
issueId: string;
|
||
companyId: string;
|
||
reason?: string;
|
||
contextSource?: string;
|
||
idempotencyKey?: string | null;
|
||
actorAgentId?: string | null;
|
||
actorUserId?: string | null;
|
||
actorRunId?: string | null;
|
||
},
|
||
result: PluginIssueWakeupResult,
|
||
];
|
||
"issues.requestWakeups": [
|
||
params: {
|
||
issueIds: string[];
|
||
companyId: string;
|
||
reason?: string;
|
||
contextSource?: string;
|
||
idempotencyKeyPrefix?: string | null;
|
||
actorAgentId?: string | null;
|
||
actorUserId?: string | null;
|
||
actorRunId?: string | null;
|
||
},
|
||
result: PluginIssueWakeupBatchResult[],
|
||
];
|
||
"issues.summaries.getOrchestration": [
|
||
params: {
|
||
issueId: string;
|
||
companyId: string;
|
||
includeSubtree?: boolean;
|
||
billingCode?: string | null;
|
||
},
|
||
result: PluginIssueOrchestrationSummary,
|
||
];
|
||
"issues.listComments": [
|
||
params: { issueId: string; companyId: string },
|
||
result: IssueComment[],
|
||
];
|
||
"issues.createComment": [
|
||
params: {
|
||
issueId: string;
|
||
body: string;
|
||
companyId: string;
|
||
authorAgentId?: string;
|
||
/** Active human company member the comment is attributed to. Requires `issue.comments.create_human_attributed`. */
|
||
actorUserId?: string;
|
||
},
|
||
result: IssueComment,
|
||
];
|
||
"issues.createInteraction": [
|
||
params: {
|
||
issueId: string;
|
||
companyId: string;
|
||
interaction: CreateIssueThreadInteraction;
|
||
authorAgentId?: string | null;
|
||
},
|
||
result: IssueThreadInteraction,
|
||
];
|
||
"issues.listInteractions": [
|
||
params: { issueId: string; companyId: string },
|
||
result: IssueThreadInteraction[],
|
||
];
|
||
"issues.respondInteraction": [
|
||
params: {
|
||
issueId: string;
|
||
interactionId: string;
|
||
companyId: string;
|
||
action: "accept" | "reject";
|
||
/**
|
||
* Active human company member the decision is attributed to. Required —
|
||
* resolving an interaction is a board-user action; the host re-verifies
|
||
* active membership at apply time and never trusts this value blindly.
|
||
*/
|
||
actorUserId?: string;
|
||
reason?: string | null;
|
||
},
|
||
result: { interaction: IssueThreadInteraction; applied: boolean },
|
||
];
|
||
"issues.listAttachments": [
|
||
params: { issueId: string; companyId: string },
|
||
result: IssueAttachment[],
|
||
];
|
||
"issues.getAttachmentContent": [
|
||
params: { attachmentId: string; companyId: string; maxBytes?: number | null },
|
||
result: PluginIssueAttachmentContent | null,
|
||
];
|
||
|
||
// Issue Documents
|
||
"issues.documents.list": [
|
||
params: { issueId: string; companyId: string },
|
||
result: IssueDocumentSummary[],
|
||
];
|
||
"issues.documents.get": [
|
||
params: { issueId: string; key: string; companyId: string },
|
||
result: IssueDocument | null,
|
||
];
|
||
"issues.documents.upsert": [
|
||
params: {
|
||
issueId: string;
|
||
key: string;
|
||
body: string;
|
||
companyId: string;
|
||
title?: string;
|
||
format?: string;
|
||
changeSummary?: string;
|
||
},
|
||
result: IssueDocument,
|
||
];
|
||
"issues.documents.delete": [
|
||
params: { issueId: string; key: string; companyId: string },
|
||
result: void,
|
||
];
|
||
|
||
// Approvals
|
||
"approvals.list": [
|
||
params: { companyId: string; status?: string | null },
|
||
result: Approval[],
|
||
];
|
||
"approvals.get": [
|
||
params: { approvalId: string; companyId: string },
|
||
result: Approval | null,
|
||
];
|
||
"approvals.decide": [
|
||
params: {
|
||
approvalId: string;
|
||
companyId: string;
|
||
action: "approve" | "reject";
|
||
/**
|
||
* Active human company member the decision is attributed to. Required —
|
||
* deciding an approval is a board-user action; the host re-verifies
|
||
* active membership at apply time and never trusts this value blindly.
|
||
*/
|
||
actorUserId?: string;
|
||
decisionNote?: string | null;
|
||
},
|
||
result: { approval: Approval; applied: boolean },
|
||
];
|
||
|
||
// Agents (read)
|
||
"agents.list": [
|
||
params: { companyId: string; status?: string; limit?: number; offset?: number },
|
||
result: Agent[],
|
||
];
|
||
"agents.get": [
|
||
params: { agentId: string; companyId: string },
|
||
result: Agent | null,
|
||
];
|
||
|
||
// Agents (write)
|
||
"agents.pause": [
|
||
params: { agentId: string; companyId: string },
|
||
result: Agent,
|
||
];
|
||
"agents.resume": [
|
||
params: { agentId: string; companyId: string },
|
||
result: Agent,
|
||
];
|
||
"agents.invoke": [
|
||
params: { agentId: string; companyId: string; prompt: string; reason?: string },
|
||
result: { runId: string },
|
||
];
|
||
"agents.managed.get": [
|
||
params: { agentKey: string; companyId: string },
|
||
result: PluginManagedAgentResolution,
|
||
];
|
||
"agents.managed.reconcile": [
|
||
params: { agentKey: string; companyId: string },
|
||
result: PluginManagedAgentResolution,
|
||
];
|
||
"agents.managed.reset": [
|
||
params: { agentKey: string; companyId: string },
|
||
result: PluginManagedAgentResolution,
|
||
];
|
||
|
||
// Agent Sessions
|
||
"agents.sessions.create": [
|
||
params: { agentId: string; companyId: string; taskKey?: string; reason?: string },
|
||
result: { sessionId: string; agentId: string; companyId: string; status: "active" | "closed"; createdAt: string },
|
||
];
|
||
"agents.sessions.list": [
|
||
params: { agentId: string; companyId: string },
|
||
result: Array<{ sessionId: string; agentId: string; companyId: string; status: "active" | "closed"; createdAt: string }>,
|
||
];
|
||
"agents.sessions.sendMessage": [
|
||
params: { sessionId: string; companyId: string; prompt: string; reason?: string },
|
||
result: { runId: string },
|
||
];
|
||
"agents.sessions.close": [
|
||
params: { sessionId: string; companyId: string },
|
||
result: void,
|
||
];
|
||
|
||
// Goals
|
||
"goals.list": [
|
||
params: { companyId: string; level?: string; status?: string; limit?: number; offset?: number },
|
||
result: Goal[],
|
||
];
|
||
"goals.get": [
|
||
params: { goalId: string; companyId: string },
|
||
result: Goal | null,
|
||
];
|
||
"goals.create": [
|
||
params: {
|
||
companyId: string;
|
||
title: string;
|
||
description?: string;
|
||
level?: string;
|
||
status?: string;
|
||
parentId?: string;
|
||
ownerAgentId?: string;
|
||
},
|
||
result: Goal,
|
||
];
|
||
"goals.update": [
|
||
params: {
|
||
goalId: string;
|
||
patch: Record<string, unknown>;
|
||
companyId: string;
|
||
},
|
||
result: Goal,
|
||
];
|
||
|
||
// Access
|
||
"access.members.list": [
|
||
params: { companyId: string; includeArchived?: boolean },
|
||
result: PluginAccessMember[],
|
||
];
|
||
"access.members.get": [
|
||
params: { memberId: string; companyId: string },
|
||
result: PluginAccessMember | null,
|
||
];
|
||
"access.members.update": [
|
||
params: {
|
||
memberId: string;
|
||
companyId: string;
|
||
patch: {
|
||
membershipRole?: string | null;
|
||
status?: "pending" | "active" | "suspended";
|
||
};
|
||
},
|
||
result: PluginAccessMember,
|
||
];
|
||
"access.invites.list": [
|
||
params: {
|
||
companyId: string;
|
||
state?: "active" | "revoked" | "accepted" | "expired";
|
||
limit?: number;
|
||
offset?: number;
|
||
},
|
||
result: { invites: PluginAccessInvite[]; nextOffset: number | null },
|
||
];
|
||
"access.invites.create": [
|
||
params: {
|
||
companyId: string;
|
||
allowedJoinTypes?: "human" | "agent" | "both";
|
||
humanRole?: string | null;
|
||
defaultsPayload?: Record<string, unknown> | null;
|
||
agentMessage?: string | null;
|
||
},
|
||
result: PluginAccessInvite & { token: string },
|
||
];
|
||
"access.invites.revoke": [
|
||
params: { inviteId: string; companyId: string },
|
||
result: PluginAccessInvite,
|
||
];
|
||
|
||
// Authorization
|
||
"authorization.grants.list": [
|
||
params: { companyId: string; principalType?: string; principalId?: string },
|
||
result: PrincipalPermissionGrant[],
|
||
];
|
||
"authorization.grants.set": [
|
||
params: {
|
||
companyId: string;
|
||
principalType: string;
|
||
principalId: string;
|
||
grants: Array<{ permissionKey: string; scope?: Record<string, unknown> | null }>;
|
||
grantedByUserId?: string | null;
|
||
},
|
||
result: PrincipalPermissionGrant[],
|
||
];
|
||
"authorization.policies.summary": [
|
||
params: { companyId: string },
|
||
result: PluginAuthorizationPolicySummary,
|
||
];
|
||
"authorization.policies.get": [
|
||
params: { companyId: string; resourceType: "company" | "agent" | "project" | "issue"; resourceId: string },
|
||
result: PluginAuthorizationPolicyRecord | null,
|
||
];
|
||
"authorization.policies.update": [
|
||
params: {
|
||
companyId: string;
|
||
resourceType: "company" | "agent" | "project" | "issue";
|
||
resourceId: string;
|
||
policy: Record<string, unknown> | null;
|
||
},
|
||
result: PluginAuthorizationPolicyRecord,
|
||
];
|
||
"authorization.policies.previewAssignment": [
|
||
params: PluginAssignmentPreviewInput,
|
||
result: PluginAuthorizationDecisionResult,
|
||
];
|
||
"authorization.policies.explainAssignment": [
|
||
params: PluginAssignmentPreviewInput,
|
||
result: PluginAuthorizationDecisionResult,
|
||
];
|
||
"authorization.audit.search": [
|
||
params: {
|
||
companyId: string;
|
||
action?: string;
|
||
actorType?: string;
|
||
actorId?: string;
|
||
entityType?: string;
|
||
entityId?: string;
|
||
decision?: string;
|
||
limit?: number;
|
||
offset?: number;
|
||
},
|
||
result: PluginAuthorizationAuditEntry[],
|
||
];
|
||
}
|
||
|
||
/** Union of all worker→host method names. */
|
||
export type WorkerToHostMethodName = keyof WorkerToHostMethods;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Worker→Host Notification Types (fire-and-forget, no response)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Typed parameter shapes for worker→host JSON-RPC notifications.
|
||
*
|
||
* Notifications are fire-and-forget — the worker does not wait for a response.
|
||
* These are used for streaming events and logging, not for request-response RPCs.
|
||
*/
|
||
export interface WorkerToHostNotifications {
|
||
/**
|
||
* Forward a stream event to connected SSE clients.
|
||
*
|
||
* Emitted by the worker for each event on a stream channel. The host
|
||
* publishes to the PluginStreamBus, which fans out to all SSE clients
|
||
* subscribed to the (pluginId, channel, companyId) tuple.
|
||
*
|
||
* The `event` payload is JSON-serializable and sent as SSE `data:`.
|
||
* The default SSE event type is `"message"`.
|
||
*/
|
||
"streams.emit": {
|
||
channel: string;
|
||
companyId: string;
|
||
event: unknown;
|
||
};
|
||
|
||
/**
|
||
* Signal that a stream channel has been opened.
|
||
*
|
||
* Emitted when the worker calls `ctx.streams.open(channel, companyId)`.
|
||
* UI clients may use this to display a "connected" indicator or begin
|
||
* buffering input. The host tracks open channels so it can emit synthetic
|
||
* close events if the worker crashes.
|
||
*/
|
||
"streams.open": {
|
||
channel: string;
|
||
companyId: string;
|
||
};
|
||
|
||
/**
|
||
* Signal that a stream channel has been closed.
|
||
*
|
||
* Emitted when the worker calls `ctx.streams.close(channel)`, or
|
||
* synthetically by the host when a worker process exits with channels
|
||
* still open. UI clients should treat this as terminal and disconnect
|
||
* the SSE connection.
|
||
*/
|
||
"streams.close": {
|
||
channel: string;
|
||
companyId: string;
|
||
};
|
||
|
||
/**
|
||
* Deliver one incremental output chunk of the active `environmentExecute`
|
||
* call to the host runner log sink.
|
||
*
|
||
* The worker emits this notification for each new `stdout` or `stderr` chunk
|
||
* while one execute call runs. The host reads the active invocation id from
|
||
* the envelope field `paperclipInvocationId`, which the worker RPC host stamps
|
||
* from the active invocation context. The host correlates the chunk to the
|
||
* host-owned execute route for that id and delivers it to that route's
|
||
* `onLog` callback.
|
||
*
|
||
* Security: the notification carries no company id on purpose. The
|
||
* invocation-to-company binding on the host execute route is authoritative.
|
||
* The host never reads a company id from this payload to select the route or
|
||
* to grant access. The `chunk` is a text string, because JSON-RPC cannot
|
||
* carry raw bytes; the host drops a chunk that is not a bounded non-empty
|
||
* string or whose stream name is not exactly `stdout` or `stderr`.
|
||
*/
|
||
"execute.log": {
|
||
stream: "stdout" | "stderr";
|
||
chunk: string;
|
||
};
|
||
}
|
||
|
||
/** Union of all worker→host notification method names. */
|
||
export type WorkerToHostNotificationName = keyof WorkerToHostNotifications;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Typed Request / Response Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* A typed JSON-RPC request for a specific host→worker method.
|
||
*/
|
||
export type HostToWorkerRequest<M extends HostToWorkerMethodName> =
|
||
JsonRpcRequest<M, HostToWorkerMethods[M][0]>;
|
||
|
||
/**
|
||
* A typed JSON-RPC success response for a specific host→worker method.
|
||
*/
|
||
export type HostToWorkerResponse<M extends HostToWorkerMethodName> =
|
||
JsonRpcSuccessResponse<HostToWorkerMethods[M][1]>;
|
||
|
||
/**
|
||
* A typed JSON-RPC request for a specific worker→host method.
|
||
*/
|
||
export type WorkerToHostRequest<M extends WorkerToHostMethodName> =
|
||
JsonRpcRequest<M, WorkerToHostMethods[M][0]>;
|
||
|
||
/**
|
||
* A typed JSON-RPC success response for a specific worker→host method.
|
||
*/
|
||
export type WorkerToHostResponse<M extends WorkerToHostMethodName> =
|
||
JsonRpcSuccessResponse<WorkerToHostMethods[M][1]>;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Message Factory Functions
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Counter for generating unique request IDs when no explicit ID is provided. */
|
||
let _nextId = 1;
|
||
|
||
/** Wrap around before reaching Number.MAX_SAFE_INTEGER to prevent precision loss. */
|
||
const MAX_SAFE_RPC_ID = Number.MAX_SAFE_INTEGER - 1;
|
||
|
||
/**
|
||
* Create a JSON-RPC 2.0 request message.
|
||
*
|
||
* @param method - The RPC method name
|
||
* @param params - Structured parameters
|
||
* @param id - Optional explicit request ID (auto-generated if omitted)
|
||
*/
|
||
export function createRequest<TMethod extends string>(
|
||
method: TMethod,
|
||
params: unknown,
|
||
id?: JsonRpcId,
|
||
): JsonRpcRequest<TMethod> {
|
||
if (_nextId >= MAX_SAFE_RPC_ID) {
|
||
_nextId = 1;
|
||
}
|
||
return {
|
||
jsonrpc: JSONRPC_VERSION,
|
||
id: id ?? _nextId++,
|
||
method,
|
||
params,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Create a JSON-RPC 2.0 success response.
|
||
*
|
||
* @param id - The request ID being responded to
|
||
* @param result - The result value
|
||
*/
|
||
export function createSuccessResponse<TResult>(
|
||
id: JsonRpcId,
|
||
result: TResult,
|
||
): JsonRpcSuccessResponse<TResult> {
|
||
return {
|
||
jsonrpc: JSONRPC_VERSION,
|
||
id,
|
||
result,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Create a JSON-RPC 2.0 error response.
|
||
*
|
||
* @param id - The request ID being responded to (null if the request ID could not be determined)
|
||
* @param code - Machine-readable error code
|
||
* @param message - Human-readable error message
|
||
* @param data - Optional structured error data
|
||
*/
|
||
export function createErrorResponse<TData = unknown>(
|
||
id: JsonRpcId | null,
|
||
code: number,
|
||
message: string,
|
||
data?: TData,
|
||
): JsonRpcErrorResponse<TData> {
|
||
const response: JsonRpcErrorResponse<TData> = {
|
||
jsonrpc: JSONRPC_VERSION,
|
||
id,
|
||
error: data !== undefined
|
||
? { code, message, data }
|
||
: { code, message } as JsonRpcError<TData>,
|
||
};
|
||
return response;
|
||
}
|
||
|
||
/**
|
||
* Create a JSON-RPC 2.0 notification (fire-and-forget, no response expected).
|
||
*
|
||
* @param method - The notification method name
|
||
* @param params - Structured parameters
|
||
*/
|
||
export function createNotification<TMethod extends string>(
|
||
method: TMethod,
|
||
params: unknown,
|
||
): JsonRpcNotification<TMethod> {
|
||
return {
|
||
jsonrpc: JSONRPC_VERSION,
|
||
method,
|
||
params,
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Type Guards
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Check whether a value is a well-formed JSON-RPC 2.0 request.
|
||
*
|
||
* A request has `jsonrpc: "2.0"`, a string `method`, and an `id`.
|
||
*/
|
||
export function isJsonRpcRequest(value: unknown): value is JsonRpcRequest {
|
||
if (typeof value !== "object" || value === null) return false;
|
||
const obj = value as Record<string, unknown>;
|
||
return (
|
||
obj.jsonrpc === JSONRPC_VERSION &&
|
||
typeof obj.method === "string" &&
|
||
"id" in obj &&
|
||
obj.id !== undefined &&
|
||
obj.id !== null
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Check whether a value is a well-formed JSON-RPC 2.0 notification.
|
||
*
|
||
* A notification has `jsonrpc: "2.0"`, a string `method`, but no `id`.
|
||
*/
|
||
export function isJsonRpcNotification(
|
||
value: unknown,
|
||
): value is JsonRpcNotification {
|
||
if (typeof value !== "object" || value === null) return false;
|
||
const obj = value as Record<string, unknown>;
|
||
return (
|
||
obj.jsonrpc === JSONRPC_VERSION &&
|
||
typeof obj.method === "string" &&
|
||
!("id" in obj)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Check whether a value is a well-formed JSON-RPC 2.0 response (success or error).
|
||
*/
|
||
export function isJsonRpcResponse(value: unknown): value is JsonRpcResponse {
|
||
if (typeof value !== "object" || value === null) return false;
|
||
const obj = value as Record<string, unknown>;
|
||
return (
|
||
obj.jsonrpc === JSONRPC_VERSION &&
|
||
"id" in obj &&
|
||
("result" in obj || "error" in obj)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Check whether a JSON-RPC response is a success response.
|
||
*/
|
||
export function isJsonRpcSuccessResponse(
|
||
response: JsonRpcResponse,
|
||
): response is JsonRpcSuccessResponse {
|
||
return "result" in response && !("error" in response && response.error !== undefined);
|
||
}
|
||
|
||
/**
|
||
* Check whether a JSON-RPC response is an error response.
|
||
*/
|
||
export function isJsonRpcErrorResponse(
|
||
response: JsonRpcResponse,
|
||
): response is JsonRpcErrorResponse {
|
||
return "error" in response && response.error !== undefined;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Serialization Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Line delimiter for JSON-RPC messages over stdio.
|
||
*
|
||
* Each message is a single line of JSON terminated by a newline character.
|
||
* This follows the newline-delimited JSON (NDJSON) convention.
|
||
*/
|
||
export const MESSAGE_DELIMITER = "\n" as const;
|
||
|
||
/**
|
||
* Serialize a JSON-RPC message to a newline-delimited string for transmission
|
||
* over stdio.
|
||
*
|
||
* @param message - Any JSON-RPC message (request, response, or notification)
|
||
* @returns The JSON string terminated with a newline
|
||
*/
|
||
export function serializeMessage(message: JsonRpcMessage): string {
|
||
return JSON.stringify(message) + MESSAGE_DELIMITER;
|
||
}
|
||
|
||
/**
|
||
* Parse a JSON string into a JSON-RPC message.
|
||
*
|
||
* Returns the parsed message or throws a `JsonRpcParseError` if the input
|
||
* is not valid JSON or does not conform to the JSON-RPC 2.0 structure.
|
||
*
|
||
* @param line - A single line of JSON text (with or without trailing newline)
|
||
* @returns The parsed JSON-RPC message
|
||
* @throws {JsonRpcParseError} If parsing fails
|
||
*/
|
||
export function parseMessage(line: string): JsonRpcMessage {
|
||
const trimmed = line.trim();
|
||
if (trimmed.length === 0) {
|
||
throw new JsonRpcParseError("Empty message");
|
||
}
|
||
|
||
let parsed: unknown;
|
||
try {
|
||
parsed = JSON.parse(trimmed);
|
||
} catch {
|
||
throw new JsonRpcParseError(`Invalid JSON: ${trimmed.slice(0, 200)}`);
|
||
}
|
||
|
||
if (typeof parsed !== "object" || parsed === null) {
|
||
throw new JsonRpcParseError("Message must be a JSON object");
|
||
}
|
||
|
||
const obj = parsed as Record<string, unknown>;
|
||
|
||
if (obj.jsonrpc !== JSONRPC_VERSION) {
|
||
throw new JsonRpcParseError(
|
||
`Invalid or missing jsonrpc version (expected "${JSONRPC_VERSION}", got ${JSON.stringify(obj.jsonrpc)})`,
|
||
);
|
||
}
|
||
|
||
// It's a valid JSON-RPC 2.0 envelope — return as-is and let the caller
|
||
// use the type guards for more specific classification.
|
||
return parsed as JsonRpcMessage;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Error Classes
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Error thrown when a JSON-RPC message cannot be parsed.
|
||
*/
|
||
export class JsonRpcParseError extends Error {
|
||
override readonly name = "JsonRpcParseError";
|
||
constructor(message: string) {
|
||
super(message);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Error thrown when a JSON-RPC call fails with a structured error response.
|
||
*
|
||
* Captures the full `JsonRpcError` so callers can inspect the code and data.
|
||
*/
|
||
export class JsonRpcCallError extends Error {
|
||
override readonly name = "JsonRpcCallError";
|
||
/** The JSON-RPC error code. */
|
||
readonly code: number;
|
||
/** Optional structured error data from the response. */
|
||
readonly data: unknown;
|
||
|
||
constructor(error: JsonRpcError) {
|
||
super(error.message);
|
||
this.code = error.code;
|
||
this.data = error.data;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Reset helper (testing only)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Reset the internal request ID counter. **For testing only.**
|
||
*
|
||
* @internal
|
||
*/
|
||
export function _resetIdCounter(): void {
|
||
_nextId = 1;
|
||
}
|