mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 08:28:48 +01:00
## Thinking Path > - Paperclip manages companies and the agents that do their work. > - Remote execution creates provider resources that need a durable owner. > - The server could create a sandbox before it saved its lease. > - A failed lease write could leave cleanup data only on the old web server disk. > - This change saves a company-scoped operation before creation and transfers ownership atomically. > - Recovery finds the original Daytona resource or records an explicit manual reconciliation outcome. ## Linked Issues or Issue Description **What happened?** A sandbox could exist without a lease after a database failure or company rebind. Failed cleanup recording could then create a local fallback file. Replacing that web server could lose the cleanup reference. **Expected behavior** A failed admission write makes no provider call. An interrupted creation retains a committed operation. Recovery must not create a second sandbox or destroy an attached lease. **Steps to reproduce** 1. Run the disposable PostgreSQL cases in `server/src/__tests__/sandbox-creation-operations.test.ts`. 2. Reject the lease insert after the controlled provider creates its resource. 3. Start a new receiver process with a different empty web disk. It must find the same resource and create one cleanup owner. Related ownership report: Refs #14672. This change addresses new allocations before lease persistence. It does not replace existing lease recovery. ## What Changed - Add an idempotent migration and a small creation-operation table. Retain original company, environment and run identities across deletion. - Commit creation intent before run, ad-hoc lease, and plugin task replacement calls. Fence duplicate requests and ambiguous lease commits. - Retain a known returned task resource ID in the existing operation before attachment. Preserve manual reconciliation and never delete an ambiguously attached resource. - Keep company and run authority checks and atomic lease transfer with the lease owner. - Reuse Daytona names, labels, read-only inspection and claimed cleanup. Keep provider payload handling in the provider. - Retain nonsecret credential identity hashes. Refuse recovery lookup under a changed credential value. - Record manual reconciliation for missing/deleted or explicitly inactive saved credentials, versions, and provider vaults. Keep temporary resolution failures retryable. Never reactivate a disabled credential. - Use transaction signals and concrete deadlines. Failed records back off independently. Manual reconciliation stays durable and quiet. - Remove new orphan-file fallback writes from the covered paths. Keep legacy file replay and its tests for cutover. - Replace obsolete fallback-producer assertions with normal-entrypoint durability tests. Keep cleanup, diagnostics and warm handoff coverage. ## Verification - [Independent current-head verification](https://github.com/paperclipai/paperclip/pull/18357#issuecomment-6534744320) passed 190/190 combined tests, 40/40 incoming TestOps tests, full workspace typecheck, and build. PR head `958af986130f25e136065ad30d1fdd843c46255c` has tree `e6c3e5b02182d5941a70ccf5bf55638df3c2e28d`, identical to tested combined commit `b9d7a9c2e9b30f0153685fb24a5f70b56132201f`. Real PostgreSQL, compiled Rust Runner and controlled object providers were used; external-S3 integration remains outside this local proof. - Current head `958af986130f25e136065ad30d1fdd843c46255c` normally merges master `8595efc20bda3ce20a9fbbf34a5d156a6fcf6d57`. The sole conflict was the maintenance test: a parameterized case now preserves both legacy-local `local_storage` and valid shared-S3 `provider_mismatch` checks, including the parent local receipt, exact version identity, missing-snapshot/no-seed refusal, and unchanged persisted row. The three master TestOps files remain intact. No production, schema, migration, or package changes were made after the fully qualified prior head `0b4d05505e`. Independent review approved the resolution; the full managed-skill-content file and Cloud guard controls pass 58/58 (105.11 seconds). [Required PR CI passed on this exact merge head, first attempt](https://github.com/paperclipai/paperclip/actions/runs/41136987412): 54 successful checks and three intended skips. The additional Paperclip review is neutral at 4/5; two external Superagent checks (Contributor trust and Security Scan) remain in progress with zero annotations and no assessment, so they are not passing scan evidence. Greptile reviewed exact head `958af986130f25e136065ad30d1fdd843c46255c` at 5/5; the final thread audit has zero unresolved threads. All completed evidence below retains its recorded revision scope. The additional Paperclip Review on `958af98613` is neutral at 4/5 with a Ship verdict and no must-fix or should-fix item. Its optional observation is that a generic create rejection remains pending/manual even when an operator believes allocation did not occur. This change preserves uncertainty because the generic create-error contract does not prove provider absence; no new error classification or provider-specific compensation was added. - [Independent final-source verification](https://github.com/paperclipai/paperclip/pull/18357#issuecomment-6534328247) passed all 189 real-PostgreSQL/Runner and controlled-fixture cases, full workspace typecheck, and build. The integration commit `c8411a8528386f4824b340ec5fdaaed1efb9ea69` has the same source tree as final PR head `0b4d05505e` (tree `bda65efc14b4c904d0f589154db77bb160641c8c`). Controlled object-provider coverage passed; the separately guarded external-S3 suite was not run because the bounded local MinIO pull was unavailable. This includes the two final compatibility fixes. - The first final-head CI attempt exposed one E2E timing failure in `disconnect-write-recovery.spec.ts:181`: the title stayed unchanged through the 15-second session-recovery poll. The same original case passed in 13.8 seconds on immediately prior head `7ec06f77b7`, and all UI/session/E2E source is identical across those heads. The failure log and CI artifacts are retained; no cause is claimed. One failed-job-only retry is authorized on this evidence, without changing a timeout, assertion, or production behavior. [The single failed-job retry completed successfully](https://github.com/paperclipai/paperclip/actions/runs/41133619710/attempts/2): all 35 E2E shard cases passed (6.6 minutes), including the original session-recovery case in 14.0 seconds. The initial failure remains disclosed and its cause remains unproven. - Final head `0b4d05505ee0aa72f2981cdff74761406960ec73` adds two independently reviewed integration fixes after the complete 7ec CI matrix exposed two deterministic failures. Server 9/12 failed legacy local-storage maintenance because the CLI reached the new Cloud server startup guard; server 5/12 found the Daytona package version did not match its bumped manifest. The failed run is retained at https://github.com/paperclipai/paperclip/actions/runs/41131204203. It is not passing evidence and was not blindly retried. - Maintenance now constructs the existing explicit storage dependency after loading the required persisted Cloud snapshot, and passes it to both maintenance owners. It can verify legacy local storage before provider cutover while normal Cloud server startup remains guarded. The original case reproduced red locally, then the complete managed-skill-content and managed-cloud-prerequisites files passed 57/57 (32.05 seconds), including missing-snapshot refusal, unchanged stored configuration, and existing server guard controls. Daytona package version now matches manifest 0.1.10; the complete bundled package identity file passed 14/14 (3.98 seconds), and explicit manifest/lifecycle controls passed 2/2 (328 intentionally filtered; 3.42 seconds). An initial workspace-filter command matched no project and is not counted as pass evidence. The final diff changes no sandbox runtime or schema behavior. [Full CI is green on this exact final head](https://github.com/paperclipai/paperclip/actions/runs/41133619710/attempts/2): 56 successful checks, three intended skips, one additional incomplete/neutral Paperclip Review, and no pending or failed checks. The neutral review is not a passing assessment. [Greptile reviewed the exact final head at 5/5](https://github.com/paperclipai/paperclip/pull/18357#issuecomment-6529396969), with no new actionable issues and all review threads resolved. - Final combined head `7ec06f77b7c14a745c569d18401f16df3d26b626` includes master managed-skills migration 0417 and shared-storage prerequisites from `198218bf55` through normal merges. Sandbox creation moves to generated `0418_curved_lady_mastermind.sql`; its idempotent SQL is byte-for-byte unchanged. The regenerated 0418 snapshot contains both sandbox creation operations and the master skill mutation receipts, and points to the master 0417 snapshot. Master SQL and journal history remain intact. Exactly five snapshots remain, 0414 through 0418. The only sandbox-specific changes since the approved credential policy are migration metadata/number and the matching test/document references; the sandbox runtime files are unchanged. - [Independent final combined verification](https://github.com/paperclipai/paperclip/pull/18357#issuecomment-6533960026) passed on integration revision `e6f05b4cf0cd`, containing final sandbox head `7ec06f77b7` and actual master `198218bf55`: migration/declaration checks, all 158 real-PostgreSQL/Runner and controlled-fixture cases (251 seconds), full workspace typecheck (81 seconds), and build (35 seconds). - Final combined migration proof: fresh real PostgreSQL has both tables. A separate original-0417 sandbox table with a retained pending row survives two applications of the renamed 0418 SQL unchanged, with the expected primary/index and foreign-key structure. Migration numbering/safety checks pass. The normal runtime migration reapply case passes 1/1 (20 intentionally filtered cases; 46.69 seconds). The first temporary qualification harness stopped before any database assertion because its external `.ts` file selected CommonJS; the `.mts` harness corrected that setup and passed. Independent review approved the exact metadata and reference diff. Earlier full suite and live-provider results below retain their stated revision scope. - Credential-policy head `c606d473b03709f27577a55a1dfc0b0d3b4359fd` handles the remaining inactive-credential review warning. The existing boundary accepts only concrete secret-owner 422 codes and exact no-details disabled/coming-soon vault messages. Unknown 422, authorization errors, provider wrappers, and temporary database/provider failures still propagate. Independent source review approved this three-file correction. No generic resolver, schema, provider protocol, retry cap, or activation behavior changed. - Creation/cleanup qualification at `c606d473b0`: all 39 cases pass against real PostgreSQL (81.27 seconds). The new cases use actual disabled/archived saved secrets, a valid disabled version, and provider vaults created ready before normal API updates disable them or mark them coming soon. Each remains manual through restart/repeated scans, with zero provider lookup and no retry deadline. The independent plugin idle blocker remains intact. The temporary 503 control retains retry ownership and later recovers one cleanup owner. Before this correction, all five inactive-state cases remained pending; that failed evidence is retained. The prior 18-case known-ID task proof below belongs to `237144fcf6`, whose helper and task path are unchanged by this correction. - Known-ID head `237144fcf69a249c0e0310563428c0f55ed6d55c` also addresses the independent task-replacement reliability warning. The existing creation operation now saves only the known nonsecret returned resource ID before expiry validation and attachment. The write is scoped to operation/company/original environment and allows only a missing or matching ID. It changes no ownership state and copies no resolved configuration, credentials, or provider payload. Independent source review approved this four-file fix. - The complete real-PostgreSQL task suite passes 18/18 (9.52 seconds). Rejected attachments retain the ID through manual reconciliation and repeated scans. A real committed attachment followed by an injected lost response retains the same active lease and attached operation, with no release or second create. Same-ID retries work; conflicting IDs and foreign companies cannot change it. The initial three regression cases failed because the ID was absent, before the fix. - The shipped Daytona manifest does not declare task support, so it cannot enter this generic replacement path. The dispatcher requires both declared task support and an environmentTask worker method. Generic drivers without creation lookup still use manual reconciliation, with a known returned ID retained when the database write can commit. No automatic lookup capability or unconditional compensation was added. - Previous boundary-fix head `8ba77def151ea85eeae2db7693254db7f87673b0` fixes the fresh review finding about missing credentials. Only known secret-owner 404 results at the recovery secret-resolution boundary select the existing manual outcome. Provider inspection and transient database/provider errors remain retryable. Independent source review approved this change. - Secret-boundary focused acceptance at `8ba77def15`: all 34 cases pass against isolated real PostgreSQL. The complete creation suite passes 16/16 (100.75 seconds); the complete cleanup coordinator suite passes 18/18 (99.83 seconds). The new cases use normal saved-secret creation and deletion, confirm zero provider lookup, restart/repeated scans, no automatic deadline, preserved independent plugin idle blocking, and no additional idle blocker after the controlled worker supplies its quiet proof. A temporary 503 resolution failure preserves pending ownership and later recovers one cleanup owner. No additional live provider resource was created. - Failed attempts remain part of the evidence. The initial red run reproduced the pending-operation bug and also exposed a numeric-deadline test harness mistake, which was corrected. A later combined run reported an existing replacement-receiver failure and a new idle-fixture assertion failure; it was interrupted while the next test file remained in imports. An isolated diagnostic confirmed that the manual state was correct but the installed fixture plugin correctly blocked sleep without its independent quiet proof. The corrected test retains that blocker assertion before supplying the controlled worker witness. The unchanged replacement-receiver case passes in the final complete 16-case run; its earlier failure is not counted as a pass or assigned a proven cause. No timeout or production idle guard was relaxed. - Greptile reviewed previous combined head `7ec06f77b7c14a745c569d18401f16df3d26b626` at 5/5, with no unresolved review threads. Its CI produced the two concrete integration failures addressed above. The earlier CI and live evidence below remain historical evidence, not a claim that the current full gates have completed. - Master integration update: merged `dbff3747b8` and then latest master `76fde891cc` without rewriting branch history. Only `doc/DATABASE.md` conflicted; the sandbox ownership, object-backed run evidence, and cloud configuration sections all remain. The master merges changed no sandbox implementation, migration, or provider code. Migration numbering/safety checks pass and exactly five schema snapshots remain. At merge head `f5d23d4a59828835d01c150f83fa2645fb2d3bec`, the two focused creation/coordinator suites pass all 32 cases against isolated real PostgreSQL (64.57 seconds). Those merge-head checks precede the missing-credential fix below. The earlier live Daytona proof still covers the unchanged normal creation/lookup/teardown path; the new missing-credential branch is qualified with real PostgreSQL and controlled provider hooks. The optional Superagent Security Scan was inconclusive on prior head `7ec06f77b7`: its pinned PR merge-base checkout failed or timed out, with a neutral conclusion and zero annotations. An earlier scan could not start within 120 seconds; a bounded rerun request returned HTTP 404. These scanner failures are not passing security-scan evidence. On final head `0b4d05505e`, the additional Paperclip Review check is neutral: its assigned review execution did not complete and produced no validated assessment. Fresh Greptile 5/5 and independent source review are separate evidence. Previous-head required CI, independent review, and the live Daytona qualification below passed. - [Complete previous-head CI](https://github.com/paperclipai/paperclip/actions/runs/41105726052) passed at `0b5cbb763ab8faa25aa0ff1d8a405cdc0d3db409`: 53 successful checks and three intended skips, including typecheck, full tests, build and E2E. Greptile is 5/5 on this head and both original findings are resolved. - Initial head: 200 PostgreSQL runtime, creation and coordinator tests passed. The corrected head also passes all 32 creation and coordinator tests, including deletion before/during lookup, a failed lookup after deletion, restart, settled polling and actual idle-safety checks. Cases cover failed admission, failed lease writes, concurrent recovery, lost provider replies, ambiguous transfer retries, company rebind, environment deletion, stopped runs, lookup backoff, restart, drain and settled SQL quiet. - Separate receiver processes: passed. A controlled HTTP provider retains the resource while three independent receiver processes use two empty web disks. Recovery creates one cleanup owner and never repeats creation. - Fresh PostgreSQL migration and two additional applications: passed. The pending creation row survives unchanged. - Server TypeScript and SDK/shared build checks pass after the corrections. Migration numbering/safety checks passed. Exactly five schema snapshots remain. - Daytona adapter: all 330 tests passed, including stable-name lookup, ownership checks, deletion and the updated manifest version. An earlier run exposed the version expectation and it was corrected. Previous-head CI passed. - Live Daytona acceptance passed on `0b5cbb763a`: isolated real PostgreSQL, normal runtime acquisition through independent receiver processes, actual Daytona plugin/SDK 0.203.0, one disposable Ubuntu 24.04 sandbox (1 CPU / 1 GiB RAM / 1 GiB disk). A forced lease insert failure retained the committed operation; a fresh receiver with empty disk found the same sandbox, repeated recovery retained one cleanup owner, and the actual claimed cleanup sweep expired the lease and resolved the operation. Independent SDK lookup returned typed not-found; ordinary recorded-ID inspection returned destroyed. This successful attempt made one create, one recovery lookup, and one teardown. Two earlier failed harness/provider-confirmation attempts are disclosed in the [live acceptance record](https://github.com/paperclipai/paperclip/pull/18357#issuecomment-6530313054). The coordinator independently queried all three exact resource IDs through the SDK and confirmed typed not-found for each. - Complete CI-affected heartbeat/plugin warm-task suites passed 21/21 after test corrections. An unchanged workspace exposure case passed in isolation after the prior CI shard reported an unhandled readiness rejection. Complete previous-head CI and refreshed review passed. ## Risks - Deleted companies retain an explicit manual reconciliation outcome. Recovery does not retry missing credentials or insert a lease for a deleted company. - Only Daytona declares automatic creation lookup. Other uncertain providers retain `needs_reconciliation`. A missing creation name alone cannot prove that a late allocation will never appear. - Pending operations use the existing process-local signal and startup recovery model. This does not add cross-replica wake delivery or prove deployed sleep. - Legacy direct provider probes and custom-image setup use separate lifecycles. They are outside this replacement of the run-lease orphan-file fallback. The normal Test path that acquires a runtime lease is covered. - Existing orphan files still require the separate cutover inventory and settlement. This change does not delete them. - The schema change is additive. The new SQL can be reapplied. Historical SQL and journal entries are preserved; only the oldest generated snapshot is pruned. ## Model Used OpenAI `gpt-6.1-sol`, with tool use, code execution and repository review. The harness did not report the context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge The required branch status checks (`ci / verify`, `ci / e2e`) and all test/build jobs passed on this head. The non-required Superagent Contributor trust and Security Scan checks are still running without findings; they are not claimed as passes. No required check or branch rule is overridden. --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
2721 lines
90 KiB
TypeScript
2721 lines
90 KiB
TypeScript
import type { PluginWorkspaceParams, PluginWorkspaceResult } from "./workspace-provider.js";
|
||
import type { PluginEnvironmentTaskParams, PluginEnvironmentTaskResult } from "./environment-tasks.js";
|
||
import type { PluginAgentRuntimeState, PluginAgentRuntimeIdentity } from "./types.js";
|
||
import type { AgentLifecycleRequest, AgentLifecycleResult } from "@paperclipai/shared";
|
||
import type { AiConnectionRouterRequest, AiConnectionRouterResult } from "@paperclipai/shared";
|
||
/**
|
||
* 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,
|
||
CreateIssueThreadInteractionInput,
|
||
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,
|
||
ResourceLifecycleEvent,
|
||
ResourceLifecycleResult,
|
||
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;
|
||
projectGit?: { projectId: string; workspaceId?: string; runId?: string; agentId?: string; operation?: "prepare" | "inspect" | "remove" };
|
||
}
|
||
|
||
/**
|
||
* 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 {
|
||
/** Host-owned aggregate timing budget; never received from the worker. */
|
||
providerSpanBudget?: { remaining: number };
|
||
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;
|
||
creationOperationId?: string;
|
||
/** Explicit platform probes may request a provider-owned immutable snapshot
|
||
* identity. Supporting providers create by that ID and return snapshotIdentity
|
||
* in lease metadata. Older providers may omit it; the host then cannot qualify
|
||
* a mutable snapshot name. Ordinary discovery must not allocate a sandbox. */
|
||
resolveSnapshotIdentity?: boolean;
|
||
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 {
|
||
/** New run authority for a retained task; omitted by legacy environment consumers. */
|
||
runId?: string;
|
||
providerLeaseId: string;
|
||
leaseMetadata?: Record<string, unknown>;
|
||
}
|
||
|
||
export interface PluginEnvironmentReleaseLeaseParams extends PluginEnvironmentDriverBaseParams {
|
||
/** Stop the exact allocation while preserving its files, regardless of its
|
||
* ordinary release policy. A failed stop must throw, never fall back to delete. */
|
||
resourceDisposition?: "stop_and_retain";
|
||
/** Explicit operator cancellation: terminate active work instead of waiting
|
||
* for command/sync activity to drain. Still requires a provider receipt. */
|
||
cancelActiveWork?: boolean;
|
||
providerLeaseId: string | null;
|
||
leaseMetadata?: Record<string, unknown>;
|
||
}
|
||
|
||
/** Returned only after the provider confirms that execution has ended. A queued
|
||
* stop request or successful local cleanup is not a termination receipt. */
|
||
export interface PluginEnvironmentTerminationReceipt {
|
||
providerLeaseId: string;
|
||
state: "stopped" | "destroyed";
|
||
}
|
||
|
||
export interface PluginEnvironmentDestroyLeaseParams extends PluginEnvironmentReleaseLeaseParams {}
|
||
|
||
/** Observe this exact allocation without starting, stopping or deleting it.
|
||
* Missing resources must be distinguished from permission/transport failures. */
|
||
export interface PluginEnvironmentInspectLeaseParams extends PluginEnvironmentDriverBaseParams {
|
||
creationOperation?: { id: string; runId: string };
|
||
providerLeaseId: string;
|
||
leaseMetadata?: Record<string, unknown>;
|
||
}
|
||
|
||
export interface PluginEnvironmentLeaseObservation {
|
||
creationCleanup?: import("./environment-creation-cleanup.js").PluginEnvironmentCreationCleanup;
|
||
providerLeaseId: string;
|
||
state: "active" | "stopped" | "destroyed" | "unknown";
|
||
}
|
||
|
||
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;
|
||
/** Optional total command execution budget, in milliseconds. A session
|
||
* provider must keep this distinct from individual observation timeouts.
|
||
* Zero disables this deadline. Absence keeps the existing session lifetime. */
|
||
commandTimeoutMs?: 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 host route identifier the open request carried. The worker echoes it,
|
||
* so the host can hold more than one concurrent login pseudo-terminal per
|
||
* worker and route each chunk to its own route.
|
||
*/
|
||
hostRouteId: string;
|
||
/** 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 host route identifier the open request carried. The worker echoes it,
|
||
* so the host can hold more than one concurrent login pseudo-terminal per
|
||
* worker and resolve the exit against its own route.
|
||
*/
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 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];
|
||
prepareIdleSleep: [params: { ownerId: string; expiresAt: number }, result: { ownerId: string; expiresAt: number; backgroundWork: "none" | "present" | "unknown" }];
|
||
releaseIdleSleep: [params: { ownerId: string }, 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 */
|
||
agentLifecycle: [params: AgentLifecycleRequest, result: AgentLifecycleResult];
|
||
resourceLifecycle: [params: ResourceLifecycleEvent, result: ResourceLifecycleResult];
|
||
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,
|
||
];
|
||
routeAiConnection: [params: AiConnectionRouterRequest, result: AiConnectionRouterResult];
|
||
resolveExternalObject: [
|
||
params: ResolveExternalObjectParams,
|
||
result: PluginExternalObjectResolveResult,
|
||
];
|
||
refreshExternalObjects: [
|
||
params: RefreshExternalObjectsParams,
|
||
result: RefreshExternalObjectsResult,
|
||
];
|
||
environmentValidateConfig: [
|
||
params: PluginEnvironmentValidateConfigParams,
|
||
result: PluginEnvironmentValidationResult,
|
||
];
|
||
workspaceOperation: [params: PluginWorkspaceParams, result: PluginWorkspaceResult];
|
||
environmentTask: [params: PluginEnvironmentTaskParams, result: PluginEnvironmentTaskResult];
|
||
environmentProbe: [
|
||
params: PluginEnvironmentProbeParams,
|
||
result: PluginEnvironmentProbeResult,
|
||
];
|
||
environmentAcquireLease: [
|
||
params: PluginEnvironmentAcquireLeaseParams,
|
||
result: PluginEnvironmentLease,
|
||
];
|
||
environmentResumeLease: [
|
||
params: PluginEnvironmentResumeLeaseParams,
|
||
result: PluginEnvironmentLease,
|
||
];
|
||
environmentReleaseLease: [
|
||
params: PluginEnvironmentReleaseLeaseParams,
|
||
result: PluginEnvironmentTerminationReceipt | void,
|
||
];
|
||
environmentStopLease: [
|
||
params: PluginEnvironmentReleaseLeaseParams,
|
||
result: PluginEnvironmentTerminationReceipt,
|
||
];
|
||
environmentDestroyLease: [
|
||
params: PluginEnvironmentDestroyLeaseParams,
|
||
result: PluginEnvironmentTerminationReceipt | void,
|
||
];
|
||
environmentInspectLease: [
|
||
params: PluginEnvironmentInspectLeaseParams,
|
||
result: PluginEnvironmentLeaseObservation,
|
||
];
|
||
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[] = [
|
||
"prepareIdleSleep",
|
||
"releaseIdleSleep",
|
||
"validateConfig",
|
||
"configChanged",
|
||
"onEvent",
|
||
"runJob",
|
||
"handleWebhook",
|
||
"handleApiRequest",
|
||
"agentLifecycle",
|
||
"resourceLifecycle",
|
||
"workspaceOperation",
|
||
"getData",
|
||
"performAction",
|
||
"executeTool",
|
||
"detectExternalObjects",
|
||
"routeAiConnection",
|
||
"resolveExternalObject",
|
||
"refreshExternalObjects",
|
||
"environmentValidateConfig",
|
||
"environmentTask",
|
||
"environmentProbe",
|
||
"environmentAcquireLease",
|
||
"environmentResumeLease",
|
||
"environmentReleaseLease",
|
||
"environmentStopLease",
|
||
"environmentDestroyLease",
|
||
"environmentInspectLease",
|
||
"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 PluginSpanRecord {
|
||
name: string;
|
||
/** A completed remote lifecycle is linked to its observer, never made its child. */
|
||
relationship?: "linked";
|
||
attributes?: Record<string, string | number | boolean>;
|
||
status?: { code: number; message?: string };
|
||
startTimeMs?: number;
|
||
endTimeMs?: number;
|
||
/** Nested timing receipts, bounded and validated again by the host. */
|
||
children?: PluginSpanRecord[];
|
||
}
|
||
|
||
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.listLifecycle": [params: { companyId: string; limit?: number; afterId?: string }, result: ResourceLifecycleEvent[]];
|
||
"events.acknowledgeLifecycle": [params: { companyId: string; eventId: string }, result: void];
|
||
"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: PluginSpanRecord,
|
||
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.resolveGitCredential": [
|
||
params: { projectId: string; workspaceId: string; companyId: string },
|
||
result: string | null,
|
||
];
|
||
"projects.getInstanceSecrets": [
|
||
params: { projectId: string; companyId: string },
|
||
result: Record<string, string>,
|
||
];
|
||
"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: CreateIssueThreadInteractionInput;
|
||
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.getRuntimeIdentity": [
|
||
params: { agentId: string; companyId: string },
|
||
result: PluginAgentRuntimeIdentity | null,
|
||
];
|
||
"agents.getRuntime": [
|
||
params: { agentId: string; companyId: string; environmentId?: string; installEnvironmentSkills?: boolean },
|
||
result: PluginAgentRuntimeState | null,
|
||
];
|
||
"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;
|
||
}
|