mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
## Thinking Path > - Paperclip runs AI agents through adapters and sandboxed execution targets. > - Duplex routes retain bytes across route data, broker messages, decoder buffers, and readiness replay. > - Per-route limits bound each route but do not bound the total retained bytes across many routes. > - A process-owned ledger must charge each retained buffer before allocation and release the charge during cleanup. > - This pull request adds the aggregate ledger, connects it to host and sandbox duplex paths, and adds route coverage. > - The benefit is a fail-closed process-wide byte limit that keeps concurrent duplex work within a safe resource budget. ## Linked Issues or Issue Description **Subsystem affected** This change affects packages/adapter-utils and server duplex orchestration. **Problem or motivation** Many routes can each stay below their per-route limits while their combined retained bytes exceed a safe process budget. **Proposed solution** Add a process-owned aggregate byte ledger. Charge route data, broker bytes, decoder buffers, and readiness replay bytes before allocation. Release each charge during cleanup. Use a separate sandbox_process decoder cap for the in-sandbox path. **Alternatives considered** Keep only per-route limits. This does not bound the combined process use. Set a fixed limit at one call site. This misses retained bytes in other duplex paths. **Roadmap alignment** This is a tightly scoped reliability and resource-safety improvement. It does not duplicate a roadmap feature. **Additional context** The aggregate ceiling uses a safe 256 MiB default. An invalid override falls back to that default and reports the rejected value. ## What Changed - Add a process-owned aggregate byte ledger for duplex route resource use. - Charge and release route data, broker forward and response bytes, decoder buffers, and readiness replay bytes. - Bound host-to-worker pending writes and standard input transport bytes. - Add a separate decoder cap for the sandbox_process path. - Make invalid aggregate-ceiling overrides fall back to the safe default without host startup failure. - Add adapter-utils and server tests for charging, release, rejection, cleanup, and many-route aggregate limits. ## Verification - pnpm --filter @paperclipai/adapter-utils exec tsc --noEmit - pnpm --filter @paperclipai/server exec tsc --noEmit - Run the focused adapter-utils duplex ledger and execution-target tests. - Run the server aggregate-ledger route test. - Confirm all required pull request checks pass on this branch. ## Risks The ledger touches several duplex buffer paths. A missed release could reduce later capacity until process restart. The tests cover charge, release, rejection, cleanup, and route aggregation. The change uses a safe default when configuration input is invalid. ## Model Used OpenAI GPT-5 Codex. The runtime model ID and context window are not exposed to this task. The model used tool calls, shell commands, and code review workflow support. ## 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 issue references) - [x] My branch name describes the change 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
3667 lines
145 KiB
TypeScript
3667 lines
145 KiB
TypeScript
/**
|
|
* PluginWorkerManager — spawns and manages out-of-process plugin worker child
|
|
* processes, routes JSON-RPC 2.0 calls over stdio, and handles lifecycle
|
|
* management including crash recovery with exponential backoff.
|
|
*
|
|
* Each installed plugin gets one dedicated worker process. The host sends
|
|
* JSON-RPC requests over the child's stdin and reads responses from stdout.
|
|
* Worker stderr is captured and forwarded to the host logger.
|
|
*
|
|
* Process Model (from PLUGIN_SPEC.md §12):
|
|
* - One worker process per installed plugin
|
|
* - Failure isolation: plugin crashes do not affect the host
|
|
* - Graceful shutdown: 10-second drain, then SIGTERM, then SIGKILL
|
|
* - Automatic restart with exponential backoff on unexpected exits
|
|
*
|
|
* @see PLUGIN_SPEC.md §12 — Process Model
|
|
* @see PLUGIN_SPEC.md §12.5 — Graceful Shutdown Policy
|
|
* @see PLUGIN_SPEC.md §13 — Host-Worker Protocol
|
|
*/
|
|
|
|
import { fork, type ChildProcess } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
import { EventEmitter } from "node:events";
|
|
import { createInterface, type Interface as ReadlineInterface } from "node:readline";
|
|
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
|
import {
|
|
JSONRPC_VERSION,
|
|
JSONRPC_ERROR_CODES,
|
|
PLUGIN_RPC_ERROR_CODES,
|
|
createRequest,
|
|
createErrorResponse,
|
|
parseMessage,
|
|
serializeMessage,
|
|
isJsonRpcResponse,
|
|
isJsonRpcRequest,
|
|
isJsonRpcNotification,
|
|
isJsonRpcSuccessResponse,
|
|
JsonRpcParseError,
|
|
JsonRpcCallError,
|
|
SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION,
|
|
SETUP_TOKEN_PTY_EXIT_NOTIFICATION,
|
|
DUPLEX_CHANNEL_DATA_NOTIFICATION,
|
|
DUPLEX_CHANNEL_EXIT_NOTIFICATION,
|
|
} from "@paperclipai/plugin-sdk";
|
|
import type {
|
|
JsonRpcId,
|
|
PluginInvocationContext,
|
|
PluginInvocationScope,
|
|
JsonRpcResponse,
|
|
JsonRpcRequest,
|
|
JsonRpcNotification,
|
|
WorkerHostCallContext,
|
|
HostToWorkerMethodName,
|
|
HostToWorkerMethods,
|
|
WorkerToHostMethodName,
|
|
WorkerToHostMethods,
|
|
InitializeParams,
|
|
} from "@paperclipai/plugin-sdk";
|
|
import { getActiveStepContext } from "@paperclipai/adapter-utils/acpx-engine/startup-timing";
|
|
import {
|
|
DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED,
|
|
type DuplexAggregateByteLedger,
|
|
type DuplexAggregateTokenOwner,
|
|
type ReservationToken,
|
|
} from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
|
|
import { CLAUDE_SETUP_TOKEN_COMMAND } from "@paperclipai/adapter-claude-local/server";
|
|
import { logger } from "../middleware/logger.js";
|
|
import { traceparentFromContextToken } from "../instrumentation.js";
|
|
|
|
/**
|
|
* The host raises this error when the child-stdin transport reservation for a
|
|
* duplex write fails against the aggregate byte ledger. The host does not write
|
|
* the frame. The write path throws it, `callInternal` rejects the RPC with it
|
|
* unwrapped, and the duplex write caller ends the route fail-closed with the
|
|
* {@link DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED} marker. Only a duplex write
|
|
* meters the transport, so this error never reaches a non-duplex control message.
|
|
*/
|
|
class DuplexAggregateBytesExceededError extends Error {
|
|
constructor() {
|
|
super(DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED);
|
|
this.name = "DuplexAggregateBytesExceededError";
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Default timeout for RPC calls in milliseconds. */
|
|
const DEFAULT_RPC_TIMEOUT_MS = 30_000;
|
|
|
|
/**
|
|
* Upper bound for the *default* RPC timeout path (15 minutes). Explicit
|
|
* caller-supplied timeouts are not subject to this cap: execute-class RPCs such
|
|
* as `environmentExecute` run entire sandboxed agent sessions in one call and
|
|
* their callers deliberately request multi-hour budgets (see
|
|
* `resolvePluginExecuteRpcTimeoutMs` in plugin-environment-driver.ts).
|
|
* Clamping those explicit budgets here killed long sandboxed runs mid-work.
|
|
*/
|
|
const MAX_RPC_TIMEOUT_MS = 15 * 60 * 1_000;
|
|
|
|
/**
|
|
* Maximum delay accepted by Node timers before Node clamps the timeout to 1ms.
|
|
* Keep accepted explicit RPC budgets inside this range before calling
|
|
* setTimeout, otherwise a huge timeout can expire almost immediately.
|
|
*/
|
|
const MAX_NODE_TIMER_TIMEOUT_MS = 2_147_483_647;
|
|
|
|
/** Timeout for the initialize RPC call. */
|
|
const INITIALIZE_TIMEOUT_MS = 15_000;
|
|
|
|
/** Timeout for the shutdown RPC call before escalating to SIGTERM. */
|
|
const SHUTDOWN_DRAIN_MS = 10_000;
|
|
|
|
/** Time to wait after SIGTERM before sending SIGKILL. */
|
|
const SIGTERM_GRACE_MS = 5_000;
|
|
|
|
/** Minimum backoff delay for crash recovery (1 second). */
|
|
const MIN_BACKOFF_MS = 1_000;
|
|
|
|
/** Maximum backoff delay for crash recovery (5 minutes). */
|
|
const MAX_BACKOFF_MS = 5 * 60 * 1_000;
|
|
|
|
/** Backoff multiplier on each consecutive crash. */
|
|
const BACKOFF_MULTIPLIER = 2;
|
|
|
|
/** Maximum number of consecutive crashes before giving up on auto-restart. */
|
|
const MAX_CONSECUTIVE_CRASHES = 10;
|
|
|
|
/** Time window in which crashes are considered consecutive (10 minutes). */
|
|
const CRASH_WINDOW_MS = 10 * 60 * 1_000;
|
|
|
|
/** Maximum number of stderr characters retained for worker failure context. */
|
|
const MAX_STDERR_EXCERPT_CHARS = 8_000;
|
|
|
|
/** Maximum characters accepted for one `execute.log` chunk. A larger chunk is
|
|
* dropped, so a faulty or hostile worker cannot flood the host with one
|
|
* unbounded notification. */
|
|
const MAX_EXECUTE_LOG_CHUNK_CHARS = 1_000_000;
|
|
|
|
/**
|
|
* Maximum characters accepted for one incoming worker stdout line before the
|
|
* host parses it as JSON. The host drops a longer line without a parse, so a
|
|
* faulty or hostile worker cannot force the host to parse an unbounded document
|
|
* and exhaust memory. The bound sits far above the largest legitimate framed
|
|
* message, so a real large command result still passes. A worker can override
|
|
* it through `WorkerStartOptions.executeLogLimits`.
|
|
*/
|
|
const MAX_WORKER_MESSAGE_CHARS = 128 * 1024 * 1024;
|
|
|
|
/**
|
|
* Default ceiling for the total characters one execute call may stream through
|
|
* `execute.log`. The host counts the delivered characters for each active
|
|
* execute route and drops further chunks past this bound, so one runaway or
|
|
* hostile execution cannot flood the host and the run-log sink without limit.
|
|
* The final command result still delivers the complete output through its own
|
|
* capture path. A worker can override it through
|
|
* `WorkerStartOptions.executeLogLimits`.
|
|
*/
|
|
const MAX_EXECUTE_LOG_TOTAL_CHARS = 128 * 1024 * 1024;
|
|
|
|
/** Maximum characters for one live login pseudo-terminal output notification. */
|
|
const MAX_SETUP_TOKEN_PTY_CHUNK_CHARS = 1_000_000;
|
|
/** Maximum cumulative output characters for one login pseudo-terminal route. */
|
|
const MAX_SETUP_TOKEN_PTY_TOTAL_CHARS = 8 * 1024 * 1024;
|
|
/** The default open timeout for one login pseudo-terminal route, in milliseconds. */
|
|
const SETUP_TOKEN_PTY_OPEN_TIMEOUT_MS = 30_000;
|
|
/** The default close timeout for one login pseudo-terminal route, in milliseconds. */
|
|
const SETUP_TOKEN_PTY_CLOSE_TIMEOUT_MS = 10_000;
|
|
/**
|
|
* The fixed non-secret error a disallowed login command returns. The manager
|
|
* forwards only the compile-time `CLAUDE_SETUP_TOKEN_COMMAND` to the worker
|
|
* pseudo-terminal. It rejects any other command before the worker call, so a
|
|
* future caller cannot spawn an arbitrary process in the sandbox.
|
|
*/
|
|
const SETUP_TOKEN_PTY_COMMAND_NOT_ALLOWED = "SETUP_TOKEN_PTY_COMMAND_NOT_ALLOWED";
|
|
/** The fixed non-secret error a rejected second credential open returns. */
|
|
const SETUP_TOKEN_PTY_ROUTE_BUSY = "SETUP_TOKEN_PTY_ROUTE_BUSY";
|
|
/** The fixed non-secret error a failed open returns. */
|
|
const SETUP_TOKEN_PTY_OPEN_FAILED = "SETUP_TOKEN_PTY_OPEN_FAILED";
|
|
|
|
// Bounds and timeouts for the generic duplex channel route. The route mirrors the
|
|
// login pseudo-terminal route, but it carries no command allowlist and adds seven
|
|
// explicit bounds the pseudo-terminal route lacks. Each bound ends the route when
|
|
// it passes the limit, so a faulty or hostile worker cannot flood the host.
|
|
/** The default maximum characters for one duplex channel data notification. */
|
|
const MAX_DUPLEX_CHANNEL_CHUNK_CHARS = 1_000_000;
|
|
/**
|
|
* The default maximum cumulative characters the host buffers for one duplex
|
|
* channel route before a data listener attaches. A worker that streams data
|
|
* before the consumer binds cannot grow the host buffer without limit.
|
|
*/
|
|
const MAX_DUPLEX_CHANNEL_PRE_BIND_CHARS = 8 * 1024 * 1024;
|
|
/**
|
|
* The default maximum number of data frames the host buffers for one duplex
|
|
* channel route before a data listener attaches.
|
|
*/
|
|
const MAX_DUPLEX_CHANNEL_PRE_BIND_FRAMES = 10_000;
|
|
/**
|
|
* The margin the pre-bind hold ceiling keeps above the pre-bind buffered-frame
|
|
* bound (`maxDuplexChannelPreBindFrames`). A worker can batch data and exit
|
|
* frames with the open reply, so the host reads them before the route binds and
|
|
* before it can apply the per-frame bounds. The host holds these frames and
|
|
* replays them after the bind. The hold ceiling bounds that hold, so a worker
|
|
* that floods frames before the open reply cannot make the host hold an
|
|
* unbounded number of frames.
|
|
*
|
|
* The hold ceiling derives from the buffered bound, not a fixed constant. A
|
|
* fixed ceiling at or below a caller-configured buffered bound would drop the
|
|
* frame that must instead trip the buffered bound during replay, so the route
|
|
* would never end. One frame of margin is enough. It lets the frame that
|
|
* exceeds the buffered bound reach the hold, so the replay's buffered-bound
|
|
* check, not the hold, ends the route.
|
|
*/
|
|
const DUPLEX_CHANNEL_PRE_BIND_HOLD_MARGIN_FRAMES = 1;
|
|
/**
|
|
* The default maximum number of in-flight host→worker requests for one duplex
|
|
* channel route. A worker that never replies cannot make the host hold an
|
|
* unbounded number of pending requests.
|
|
*/
|
|
const MAX_DUPLEX_CHANNEL_PENDING_REQUESTS = 256;
|
|
/** The default maximum characters for one host→worker duplex channel write. */
|
|
const MAX_DUPLEX_CHANNEL_WRITE_CHARS = 1_000_000;
|
|
/**
|
|
* The default maximum number of protocol errors for one duplex channel route.
|
|
* A protocol error is one malformed or mismatched data frame. The route ends
|
|
* when the count passes this budget, so a flood of bad frames bounds the route.
|
|
*/
|
|
const MAX_DUPLEX_CHANNEL_PROTOCOL_ERRORS = 100;
|
|
/**
|
|
* The default maximum cumulative bytes the host forwards for one duplex channel
|
|
* route over its whole life. The host counts the bytes of every inbound chunk,
|
|
* before and after a data listener attaches. The route ends when the count
|
|
* passes this cap, so an active route with a bound listener cannot stream an
|
|
* unbounded number of bytes.
|
|
*/
|
|
const MAX_DUPLEX_CHANNEL_TOTAL_DATA_BYTES = 256 * 1024 * 1024;
|
|
/**
|
|
* The default maximum lifetime for one duplex channel route, in milliseconds.
|
|
* The host starts a timer when the route opens and ends the route when the
|
|
* timer expires, so a route cannot live without limit.
|
|
*/
|
|
const MAX_DUPLEX_CHANNEL_DURATION_MS = 60 * 60 * 1000;
|
|
/** The default open timeout for one duplex channel route, in milliseconds. */
|
|
const DUPLEX_CHANNEL_OPEN_TIMEOUT_MS = 30_000;
|
|
/** The default close timeout for one duplex channel route, in milliseconds. */
|
|
const DUPLEX_CHANNEL_CLOSE_TIMEOUT_MS = 10_000;
|
|
/** The fixed non-secret error a rejected second duplex channel open returns. */
|
|
const DUPLEX_CHANNEL_ROUTE_BUSY = "DUPLEX_CHANNEL_ROUTE_BUSY";
|
|
/** The fixed non-secret error a failed duplex channel open returns. */
|
|
const DUPLEX_CHANNEL_OPEN_FAILED = "DUPLEX_CHANNEL_OPEN_FAILED";
|
|
|
|
// The process-scoped monotonic route-generation source. The host mints one
|
|
// strictly increasing, non-reusable `hostRouteId` on each duplex channel open,
|
|
// across every worker in the process. A retired generation never returns, so a
|
|
// late frame for a closed pair never collides with a new open. The host owns the
|
|
// value; no worker field sets it.
|
|
let duplexHostRouteIdSequence = 0;
|
|
/** Mint the next monotonic, non-reusable host route identifier for a duplex channel open. */
|
|
function nextDuplexHostRouteId(): string {
|
|
duplexHostRouteIdSequence += 1;
|
|
return `duplex-route-${duplexHostRouteIdSequence}`;
|
|
}
|
|
|
|
// The separator between the two identifiers of a duplex route pair key. It is a
|
|
// null byte, which appears in neither a host route id nor a worker session id, so
|
|
// two distinct pairs never collapse to one key.
|
|
const DUPLEX_PAIR_KEY_SEPARATOR = "\u0000";
|
|
/** Build the exact-pair routing key from the host route id and the worker session id. */
|
|
function duplexPairKey(hostRouteId: string, workerSessionId: string): string {
|
|
return `${hostRouteId}${DUPLEX_PAIR_KEY_SEPARATOR}${workerSessionId}`;
|
|
}
|
|
|
|
// The maximum number of tombstoned duplex pairs one worker retains. The worker
|
|
// keeps every tombstone until it retires, so a closed pair never returns. Before
|
|
// the set would exceed this bound, the host retires the worker, which drops every
|
|
// route and every tombstone at once. A tombstone overflow fails closed; the host
|
|
// never evicts a tombstone and lets a pair return.
|
|
const MAX_DUPLEX_ROUTE_TOMBSTONES = 4096;
|
|
|
|
/** Minimum time between two dropped-`execute.log` debug records. The router
|
|
* rate-limits the record so a flood of dropped chunks writes at most one line
|
|
* per window with a running count. */
|
|
const EXECUTE_LOG_DROP_LOG_INTERVAL_MS = 1_000;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Status of a managed worker process.
|
|
*/
|
|
export type WorkerStatus =
|
|
| "stopped"
|
|
| "starting"
|
|
| "running"
|
|
| "stopping"
|
|
| "crashed"
|
|
| "backoff";
|
|
|
|
/**
|
|
* Worker-to-host method handler. The host registers these to service calls
|
|
* that the plugin worker makes back to the host (e.g. state.get, events.emit).
|
|
*/
|
|
export type WorkerToHostHandler<M extends WorkerToHostMethodName> = (
|
|
params: WorkerToHostMethods[M][0],
|
|
context?: WorkerHostCallContext,
|
|
) => Promise<WorkerToHostMethods[M][1]>;
|
|
|
|
/**
|
|
* A map of all worker-to-host method handlers provided by the host.
|
|
*/
|
|
export type WorkerToHostHandlers = {
|
|
[M in WorkerToHostMethodName]?: WorkerToHostHandler<M>;
|
|
};
|
|
|
|
/**
|
|
* Events emitted by a PluginWorkerHandle.
|
|
*/
|
|
export interface WorkerHandleEvents {
|
|
/** Worker process started and is ready (initialize succeeded). */
|
|
"ready": { pluginId: string };
|
|
/** Worker process exited. */
|
|
"exit": { pluginId: string; code: number | null; signal: NodeJS.Signals | null };
|
|
/** Worker process crashed unexpectedly. */
|
|
"crash": { pluginId: string; code: number | null; signal: NodeJS.Signals | null; willRestart: boolean };
|
|
/** Worker process errored (e.g. spawn failure). */
|
|
"error": { pluginId: string; error: Error };
|
|
/** Worker status changed. */
|
|
"status": { pluginId: string; status: WorkerStatus; previousStatus: WorkerStatus };
|
|
}
|
|
|
|
type WorkerHandleEventName = keyof WorkerHandleEvents;
|
|
|
|
export function appendStderrExcerpt(current: string, chunk: string): string {
|
|
const next = current ? `${current}\n${chunk}` : chunk;
|
|
return next.length <= MAX_STDERR_EXCERPT_CHARS
|
|
? next
|
|
: next.slice(-MAX_STDERR_EXCERPT_CHARS);
|
|
}
|
|
|
|
export function formatWorkerFailureMessage(message: string, stderrExcerpt: string): string {
|
|
const excerpt = stderrExcerpt.trim();
|
|
if (!excerpt) return message;
|
|
if (message.includes(excerpt)) return message;
|
|
return `${message}\n\nWorker stderr:\n${excerpt}`;
|
|
}
|
|
|
|
/**
|
|
* Resolve the effective timeout for an RPC call.
|
|
*
|
|
* An explicit, positive, finite caller-supplied timeout bypasses the 15-minute
|
|
* RPC cap after normalization to Node's timer-safe integer range. Callers that
|
|
* pass one (e.g. the environment driver for `environmentExecute`) own their
|
|
* budget, and independent inactivity/safety guards bound hung runs. Only the
|
|
* default path (no usable explicit timeout) is clamped to MAX_RPC_TIMEOUT_MS so
|
|
* ordinary plugin calls stay bounded.
|
|
*/
|
|
export function resolveRpcCallTimeoutMs(
|
|
explicitTimeoutMs: number | undefined,
|
|
defaultTimeoutMs: number,
|
|
): number {
|
|
if (
|
|
explicitTimeoutMs !== undefined &&
|
|
Number.isFinite(explicitTimeoutMs) &&
|
|
explicitTimeoutMs > 0
|
|
) {
|
|
return Math.min(Math.max(Math.trunc(explicitTimeoutMs), 1), MAX_NODE_TIMER_TIMEOUT_MS);
|
|
}
|
|
return Math.min(defaultTimeoutMs, MAX_RPC_TIMEOUT_MS);
|
|
}
|
|
|
|
/**
|
|
* Options for starting a worker process.
|
|
*/
|
|
/**
|
|
* The process-scoped aggregate route-slot controller. The manager injects one
|
|
* shared instance into every worker handle, so the ceiling counts every
|
|
* concurrent duplex route across the process, not one agent's setting.
|
|
*/
|
|
export interface DuplexRouteSlotController {
|
|
/** Reserve one route slot. Return true when a slot was free and is now held. */
|
|
tryAcquire(): boolean;
|
|
/** Release one held route slot. */
|
|
release(): void;
|
|
}
|
|
|
|
export interface WorkerStartOptions {
|
|
/** Absolute path to the plugin worker entrypoint (CJS bundle). */
|
|
entrypointPath: string;
|
|
/** Plugin manifest. */
|
|
manifest: PaperclipPluginManifestV1;
|
|
/** Resolved plugin configuration. */
|
|
config: Record<string, unknown>;
|
|
/** Host instance information for the initialize call. */
|
|
instanceInfo: {
|
|
instanceId: string;
|
|
hostVersion: string;
|
|
};
|
|
/** Host API version. */
|
|
apiVersion: number;
|
|
/** Host-derived plugin database namespace, when declared. */
|
|
databaseNamespace?: string | null;
|
|
/** Handlers for worker→host RPC calls. */
|
|
hostHandlers: WorkerToHostHandlers;
|
|
/** Default timeout for RPC calls (ms). Defaults to 30s. */
|
|
rpcTimeoutMs?: number;
|
|
/** Whether to auto-restart on crash. Defaults to true. */
|
|
autoRestart?: boolean;
|
|
/** Node.js execArgv passed to the child process. */
|
|
execArgv?: string[];
|
|
/** Environment variables passed to the child process. */
|
|
env?: Record<string, string>;
|
|
/**
|
|
* The process-scoped aggregate route-slot controller for the duplex channel
|
|
* ceiling. The manager injects one shared instance into every worker handle.
|
|
* When it is absent, the worker admits every duplex open unbounded (a unit test
|
|
* constructs a handle this way).
|
|
*/
|
|
duplexRouteSlots?: DuplexRouteSlotController | null;
|
|
/**
|
|
* The process-owned aggregate byte ledger for the duplex channel. The manager
|
|
* injects one shared instance into every worker handle, so one gauge bounds the
|
|
* aggregate retained bytes across every route in the process. When it is absent,
|
|
* the worker retains duplex bytes unbounded (a unit test constructs a handle
|
|
* this way). The manager never makes a fresh per-handle default.
|
|
*/
|
|
duplexAggregateByteLedger?: DuplexAggregateByteLedger | null;
|
|
/**
|
|
* Companies this worker may act on from proactive (no-invocation) worker→host
|
|
* calls — the plugin's configured companies. Seeded onto the handle at
|
|
* creation, BEFORE the child process spawns, so a proactive plugin that
|
|
* issues host calls during setup() (e.g. the chat gateway's one-shot
|
|
* `events.subscribe`, which runs while `startWorker` is still awaiting the
|
|
* initialize response) is already authorized when those calls arrive. The set
|
|
* can still be replaced at runtime via `setProactiveCompanyScopes` (e.g. on a
|
|
* config change). Never widens access beyond the listed companies (LOOA-695).
|
|
*/
|
|
proactiveCompanyScopes?: readonly string[];
|
|
/**
|
|
* Callback for stream notifications from the worker (streams.open/emit/close).
|
|
* The host wires this to the PluginStreamBus to fan out events to SSE clients.
|
|
*/
|
|
onStreamNotification?: (method: string, params: Record<string, unknown>) => void;
|
|
/**
|
|
* Framing and flood limits for the `execute.log` route. The defaults bound
|
|
* one incoming line before the JSON parse and the total streamed output for
|
|
* one execute call. A test overrides them to exercise the drop paths without
|
|
* huge inputs.
|
|
*/
|
|
executeLogLimits?: {
|
|
/** Max characters for one incoming worker line before the JSON parse. */
|
|
maxIncomingMessageChars?: number;
|
|
/** Max total characters one execute call may stream through `execute.log`. */
|
|
maxTotalCharsPerExecute?: number;
|
|
};
|
|
|
|
/**
|
|
* Bounds and timeouts for the login pseudo-terminal route. The
|
|
* defaults bound one output notification, the cumulative output per route, and
|
|
* the open and the close timeouts. A test overrides them to exercise the
|
|
* terminalize paths without huge inputs or long waits.
|
|
*/
|
|
setupTokenPtyLimits?: {
|
|
/** Max characters for one login pseudo-terminal output notification. */
|
|
maxChunkChars?: number;
|
|
/** Max cumulative output characters for one login pseudo-terminal route. */
|
|
maxTotalChars?: number;
|
|
/** The open timeout for one login pseudo-terminal route, in milliseconds. */
|
|
openTimeoutMs?: number;
|
|
/** The close timeout for one login pseudo-terminal route, in milliseconds. */
|
|
closeTimeoutMs?: number;
|
|
};
|
|
|
|
/**
|
|
* Bounds and timeouts for the generic duplex channel route. The defaults bound
|
|
* one data notification, the pre-bind buffer, the in-flight request count, one
|
|
* host→worker write, the protocol-error budget, and the open and close
|
|
* timeouts. A test overrides them to exercise each bound without huge inputs or
|
|
* long waits.
|
|
*/
|
|
duplexChannelLimits?: {
|
|
/** Max characters for one duplex channel data notification. */
|
|
maxChunkChars?: number;
|
|
/** Max cumulative characters the host buffers before a data listener attaches. */
|
|
maxPreBindBufferedChars?: number;
|
|
/** Max number of data frames the host buffers before a data listener attaches. */
|
|
maxPreBindBufferedFrames?: number;
|
|
/** Max number of in-flight host→worker requests for one route. */
|
|
maxPendingRequests?: number;
|
|
/** Max characters for one host→worker duplex channel write. */
|
|
maxWriteChars?: number;
|
|
/** Max number of protocol errors for one route before the route ends. */
|
|
maxProtocolErrors?: number;
|
|
/** Max cumulative bytes the host forwards for one route over its whole life. */
|
|
maxTotalDataBytes?: number;
|
|
/** The maximum lifetime for one route, in milliseconds. */
|
|
maxDurationMs?: number;
|
|
/** The open timeout for one duplex channel route, in milliseconds. */
|
|
openTimeoutMs?: number;
|
|
/** The close timeout for one duplex channel route, in milliseconds. */
|
|
closeTimeoutMs?: number;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A pending RPC call waiting for a response from the worker.
|
|
*/
|
|
interface PendingRequest {
|
|
/** The request ID. */
|
|
id: JsonRpcId;
|
|
/** Method name (for logging). */
|
|
method: string;
|
|
/** Resolve the promise with the response. */
|
|
resolve: (response: JsonRpcResponse) => void;
|
|
/** Timeout timer handle. */
|
|
timer: ReturnType<typeof setTimeout>;
|
|
/** Timestamp when the request was sent. */
|
|
sentAt: number;
|
|
/** Active host-owned invocation id attached to this host→worker call. */
|
|
invocationId?: string;
|
|
}
|
|
|
|
interface ActiveInvocation {
|
|
scope: PluginInvocationScope;
|
|
timer?: ReturnType<typeof setTimeout>;
|
|
// The host-minted W3C `traceparent` for the active startup span, or undefined
|
|
// when no startup span is active. The span host handler reads it to mint the
|
|
// parentage, so a worker never supplies the parent itself.
|
|
traceparent?: string;
|
|
}
|
|
|
|
/**
|
|
* Sink for one incremental output chunk of an active `environmentExecute` call.
|
|
* The host runner passes it to `call` for the execute method, and the manager
|
|
* delivers each `execute.log` chunk to it. The sink may return a promise; the
|
|
* caller owns the ordering.
|
|
*/
|
|
export type ExecuteLogSink = (
|
|
stream: "stdout" | "stderr",
|
|
chunk: string,
|
|
) => void | Promise<void>;
|
|
|
|
/**
|
|
* The input the manager needs to open one live login pseudo-terminal route
|
|
* The manager mints the host route identifier; the caller supplies
|
|
* only the sandbox scope, the provider lease id, and the fixed command.
|
|
*/
|
|
export interface SetupTokenPtyOpenInput {
|
|
driverKey: string;
|
|
companyId: string;
|
|
environmentId: string;
|
|
providerLeaseId: string;
|
|
command: string;
|
|
}
|
|
|
|
/**
|
|
* One live login pseudo-terminal session the manager hands to the login
|
|
* transport. The shape matches the sandbox provider setup-token
|
|
* pseudo-terminal session, so the transport consumes it with no adapter.
|
|
*/
|
|
export interface SetupTokenPtyHostSession {
|
|
/** 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 or the route terminalizes. */
|
|
wait(): Promise<{ exitCode: number | null }>;
|
|
/** Stops the child process. Safe to call more than one time. */
|
|
kill(): void;
|
|
/** Closes the route and releases the terminal. Safe to call more than one time. */
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* The input the manager needs to open one generic duplex channel. The manager
|
|
* mints the host route identifier. The caller supplies the sandbox scope, the
|
|
* provider lease id, and the command. The duplex channel carries no command
|
|
* allowlist, so the caller owns the command.
|
|
*/
|
|
export interface DuplexChannelOpenInput {
|
|
driverKey: string;
|
|
companyId: string;
|
|
environmentId: string;
|
|
providerLeaseId: string;
|
|
/**
|
|
* The command argument vector the worker runs on the channel. Element 0 is the
|
|
* program. The worker runs the vector with no shell, so a shell metacharacter
|
|
* in an element cannot inject a command.
|
|
*/
|
|
command: readonly string[];
|
|
}
|
|
|
|
/**
|
|
* One live duplex channel the manager hands to a caller. The shape matches the
|
|
* login pseudo-terminal session, so a caller consumes one live bidirectional
|
|
* stream with the same methods.
|
|
*/
|
|
export interface DuplexChannelHostSession {
|
|
/** Registers the one data listener. The session streams each raw chunk in order. */
|
|
onData(listener: (chunk: string) => void): void;
|
|
/** Writes raw input bytes to the channel. */
|
|
write(data: string): void;
|
|
/**
|
|
* Resolves when the command ends or the route ends. A numeric `exitCode` is a
|
|
* real process exit. `transportClosed` is true when the provider transport
|
|
* closed with no exit data, so the broker can tell a real process exit from a
|
|
* reason-less transport close.
|
|
*/
|
|
wait(): Promise<{ exitCode: number | null; transportClosed?: boolean }>;
|
|
/** Stops the child process. Safe to call more than one time. */
|
|
kill(): void;
|
|
/** Closes the route and releases the channel. Safe to call more than one time. */
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Host-owned route for one active execute call. The host mints the invocation
|
|
* id and stores the exact company id and log sink here. A worker never selects
|
|
* this record; the host looks it up by the host-issued invocation id on the
|
|
* message envelope. The company id is the single authority for the delivery
|
|
* target, so an `execute.log` notification never carries a company id.
|
|
*/
|
|
interface ExecuteLogRoute {
|
|
companyId: string;
|
|
onLog: ExecuteLogSink;
|
|
/**
|
|
* The count of characters delivered through this route. The router bounds the
|
|
* per-execute total and drops chunks past the configured ceiling.
|
|
*/
|
|
deliveredChars: number;
|
|
/**
|
|
* Latched when the router cannot bind the shared worker pipe to a single
|
|
* company, because a second company's execute overlapped this one. After the
|
|
* latch the router drops every further chunk for this route and lets the final
|
|
* command result deliver the complete output. The latch keeps the delivered
|
|
* prefix contiguous, so the run log never shows a gap.
|
|
*/
|
|
crossCompanyBlocked: boolean;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PluginWorkerHandle — manages a single worker process
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Handle for a single plugin worker process.
|
|
*
|
|
* Callers use `start()` to spawn the worker, `call()` to send RPC requests,
|
|
* and `stop()` to gracefully shut down. The handle manages crash recovery
|
|
* with exponential backoff automatically when `autoRestart` is enabled.
|
|
*/
|
|
export interface PluginWorkerHandle {
|
|
/** The plugin ID this worker serves. */
|
|
readonly pluginId: string;
|
|
|
|
/** Current worker status. */
|
|
readonly status: WorkerStatus;
|
|
|
|
/** Start the worker process. Resolves when initialize completes. */
|
|
start(): Promise<void>;
|
|
|
|
/**
|
|
* Stop the worker process gracefully.
|
|
*
|
|
* Sends a `shutdown` RPC call, waits up to 10 seconds for the worker to
|
|
* exit, then escalates to SIGTERM, and finally SIGKILL if needed.
|
|
*/
|
|
stop(): Promise<void>;
|
|
|
|
/**
|
|
* Restart the worker process (stop + start).
|
|
*/
|
|
restart(): Promise<void>;
|
|
|
|
/**
|
|
* Send a typed host→worker RPC call.
|
|
*
|
|
* @param method - The RPC method name
|
|
* @param params - Method parameters
|
|
* @param timeoutMs - Optional per-call timeout override
|
|
* @returns The method result
|
|
* @throws {JsonRpcCallError} if the worker returns an error response
|
|
* @throws {Error} if the worker is not running or the call times out
|
|
*/
|
|
call<M extends HostToWorkerMethodName>(
|
|
method: M,
|
|
params: HostToWorkerMethods[M][0],
|
|
timeoutMs?: number,
|
|
executeLogSink?: ExecuteLogSink,
|
|
): Promise<HostToWorkerMethods[M][1]>;
|
|
|
|
/**
|
|
* Send a fire-and-forget notification to the worker (no response expected).
|
|
*/
|
|
notify(method: string, params: unknown): void;
|
|
|
|
/**
|
|
* Open one live login pseudo-terminal route on this worker. The
|
|
* manager mints the host route identifier, reserves the route, drives the open,
|
|
* binds the worker session identifier one time, and returns a session the login
|
|
* transport drives. It permits one active credential pseudo-terminal per worker.
|
|
*/
|
|
openSetupTokenPtySession(
|
|
input: SetupTokenPtyOpenInput,
|
|
): Promise<SetupTokenPtyHostSession>;
|
|
|
|
/**
|
|
* Open one generic duplex channel on this worker. The manager mints the host
|
|
* route identifier, reserves the route, drives the open, binds the worker
|
|
* session identifier one time, and returns a session a caller drives. It
|
|
* permits one active duplex channel per worker. It enforces five explicit
|
|
* bounds and ends the route when a bound passes its limit.
|
|
*/
|
|
openDuplexChannel(
|
|
input: DuplexChannelOpenInput,
|
|
): Promise<DuplexChannelHostSession>;
|
|
|
|
/**
|
|
* Authorize the set of companies this worker may act on from proactive
|
|
* (non-invocation) context. Replaces any previously-authorized set. See the
|
|
* proactive-company-scope note in `createPluginWorkerHandle` for rationale.
|
|
*/
|
|
setProactiveCompanyScopes(companyIds: readonly string[]): void;
|
|
|
|
/** Subscribe to worker events. */
|
|
on<K extends WorkerHandleEventName>(
|
|
event: K,
|
|
listener: (payload: WorkerHandleEvents[K]) => void,
|
|
): void;
|
|
|
|
/** Unsubscribe from worker events. */
|
|
off<K extends WorkerHandleEventName>(
|
|
event: K,
|
|
listener: (payload: WorkerHandleEvents[K]) => void,
|
|
): void;
|
|
|
|
/** Optional methods the worker reported during initialization. */
|
|
readonly supportedMethods: string[];
|
|
|
|
/** Get diagnostic info about the worker. */
|
|
diagnostics(): WorkerDiagnostics;
|
|
}
|
|
|
|
/**
|
|
* Diagnostic information about a worker process.
|
|
*/
|
|
export interface WorkerDiagnostics {
|
|
pluginId: string;
|
|
status: WorkerStatus;
|
|
pid: number | null;
|
|
uptime: number | null;
|
|
consecutiveCrashes: number;
|
|
totalCrashes: number;
|
|
pendingRequests: number;
|
|
lastCrashAt: number | null;
|
|
nextRestartAt: number | null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PluginWorkerManager — manages all plugin workers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* The top-level manager that holds all plugin worker handles.
|
|
*
|
|
* Provides a registry of workers keyed by plugin ID, with convenience methods
|
|
* for starting/stopping all workers and routing RPC calls.
|
|
*/
|
|
export interface PluginWorkerManager {
|
|
/**
|
|
* Register and start a worker for a plugin.
|
|
*
|
|
* @returns The worker handle
|
|
* @throws if a worker is already registered for this plugin
|
|
*/
|
|
startWorker(pluginId: string, options: WorkerStartOptions): Promise<PluginWorkerHandle>;
|
|
|
|
/**
|
|
* Stop and unregister a specific plugin worker.
|
|
*/
|
|
stopWorker(pluginId: string): Promise<void>;
|
|
|
|
/**
|
|
* Get the worker handle for a plugin.
|
|
*/
|
|
getWorker(pluginId: string): PluginWorkerHandle | undefined;
|
|
|
|
/**
|
|
* Check if a worker is registered and running for a plugin.
|
|
*/
|
|
isRunning(pluginId: string): boolean;
|
|
|
|
/**
|
|
* Authorize the companies a plugin's worker may act on from proactive
|
|
* (non-invocation) context. No-op if the worker is not registered.
|
|
*/
|
|
setProactiveCompanyScopes(pluginId: string, companyIds: readonly string[]): void;
|
|
|
|
/**
|
|
* Stop all managed workers. Called during server shutdown.
|
|
*/
|
|
stopAll(): Promise<void>;
|
|
|
|
/**
|
|
* Get diagnostic info for all workers.
|
|
*/
|
|
diagnostics(): WorkerDiagnostics[];
|
|
|
|
/**
|
|
* Send an RPC call to a specific plugin worker.
|
|
*
|
|
* @throws if the worker is not running
|
|
*/
|
|
call<M extends HostToWorkerMethodName>(
|
|
pluginId: string,
|
|
method: M,
|
|
params: HostToWorkerMethods[M][0],
|
|
timeoutMs?: number,
|
|
executeLogSink?: ExecuteLogSink,
|
|
): Promise<HostToWorkerMethods[M][1]>;
|
|
|
|
/**
|
|
* Open one live login pseudo-terminal route on a specific plugin worker
|
|
* See {@link PluginWorkerHandle.openSetupTokenPtySession}.
|
|
*
|
|
* @throws if the worker is not registered.
|
|
*/
|
|
openSetupTokenPtySession(
|
|
pluginId: string,
|
|
input: SetupTokenPtyOpenInput,
|
|
): Promise<SetupTokenPtyHostSession>;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Implementation: createPluginWorkerHandle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Create a handle for a single plugin worker process.
|
|
*
|
|
* @internal Exported for testing; consumers should use `createPluginWorkerManager`.
|
|
*/
|
|
export function createPluginWorkerHandle(
|
|
pluginId: string,
|
|
options: WorkerStartOptions,
|
|
): PluginWorkerHandle {
|
|
const log = logger.child({ service: "plugin-worker", pluginId });
|
|
const emitter = new EventEmitter();
|
|
/**
|
|
* Higher than default (10) to accommodate multiple subscribers to
|
|
* crash/ready/exit events during integration tests and runtime monitoring.
|
|
*/
|
|
emitter.setMaxListeners(50);
|
|
|
|
// Worker process state
|
|
let childProcess: ChildProcess | null = null;
|
|
let readline: ReadlineInterface | null = null;
|
|
let stderrReadline: ReadlineInterface | null = null;
|
|
let status: WorkerStatus = "stopped";
|
|
let startedAt: number | null = null;
|
|
let stderrExcerpt = "";
|
|
|
|
// Pending RPC requests awaiting a response
|
|
const pendingRequests = new Map<string | number, PendingRequest>();
|
|
let nextRequestId = 1;
|
|
const activeInvocations = new Map<string, ActiveInvocation>();
|
|
// Host-owned execute routes, keyed by the host-issued invocation id. Only an
|
|
// `environmentExecute` call with a log sink registers a route here. The
|
|
// `execute.log` router delivers only through this map — never through the
|
|
// generic `activeInvocations` record — so a non-execute call can never become
|
|
// a log target.
|
|
const activeExecuteRoutes = new Map<string, ExecuteLogRoute>();
|
|
// Rate-limit state for dropped `execute.log` notifications. The debug record
|
|
// never carries chunk bytes.
|
|
let executeLogDropCount = 0;
|
|
let executeLogDropLoggedAtMs = 0;
|
|
// Rate-limit state for dropped oversized worker lines. The warn record carries
|
|
// only the length, never the line bytes.
|
|
let oversizedLineDropCount = 0;
|
|
let oversizedLineLoggedAtMs = 0;
|
|
|
|
// Framing and flood limits for the `execute.log` route. The defaults bound one
|
|
// incoming line before the JSON parse and the total streamed output for one
|
|
// execute call. A caller (a test) can lower them.
|
|
const maxIncomingMessageChars =
|
|
options.executeLogLimits?.maxIncomingMessageChars ?? MAX_WORKER_MESSAGE_CHARS;
|
|
const maxExecuteLogTotalChars =
|
|
options.executeLogLimits?.maxTotalCharsPerExecute ?? MAX_EXECUTE_LOG_TOTAL_CHARS;
|
|
|
|
// Bounds and timeouts for the login pseudo-terminal route. A caller
|
|
// (a test) can lower them to exercise the terminalize paths.
|
|
const maxSetupTokenPtyChunkChars =
|
|
options.setupTokenPtyLimits?.maxChunkChars ?? MAX_SETUP_TOKEN_PTY_CHUNK_CHARS;
|
|
const maxSetupTokenPtyTotalChars =
|
|
options.setupTokenPtyLimits?.maxTotalChars ?? MAX_SETUP_TOKEN_PTY_TOTAL_CHARS;
|
|
const setupTokenPtyOpenTimeoutMs =
|
|
options.setupTokenPtyLimits?.openTimeoutMs ?? SETUP_TOKEN_PTY_OPEN_TIMEOUT_MS;
|
|
const setupTokenPtyCloseTimeoutMs =
|
|
options.setupTokenPtyLimits?.closeTimeoutMs ?? SETUP_TOKEN_PTY_CLOSE_TIMEOUT_MS;
|
|
|
|
// Bounds and timeouts for the generic duplex channel route. A caller (a test)
|
|
// can lower them to exercise each bound and the terminalize paths.
|
|
const maxDuplexChannelChunkChars =
|
|
options.duplexChannelLimits?.maxChunkChars ?? MAX_DUPLEX_CHANNEL_CHUNK_CHARS;
|
|
const maxDuplexChannelPreBindChars =
|
|
options.duplexChannelLimits?.maxPreBindBufferedChars ??
|
|
MAX_DUPLEX_CHANNEL_PRE_BIND_CHARS;
|
|
const maxDuplexChannelPreBindFrames =
|
|
options.duplexChannelLimits?.maxPreBindBufferedFrames ??
|
|
MAX_DUPLEX_CHANNEL_PRE_BIND_FRAMES;
|
|
// The pre-bind hold ceiling stays one frame above the buffered bound, so the
|
|
// replay, not the hold, ends the route on the buffered bound. See
|
|
// DUPLEX_CHANNEL_PRE_BIND_HOLD_MARGIN_FRAMES for why the margin must hold.
|
|
const maxDuplexChannelPreBindHoldFrames =
|
|
maxDuplexChannelPreBindFrames + DUPLEX_CHANNEL_PRE_BIND_HOLD_MARGIN_FRAMES;
|
|
const maxDuplexChannelPendingRequests =
|
|
options.duplexChannelLimits?.maxPendingRequests ??
|
|
MAX_DUPLEX_CHANNEL_PENDING_REQUESTS;
|
|
const maxDuplexChannelWriteChars =
|
|
options.duplexChannelLimits?.maxWriteChars ?? MAX_DUPLEX_CHANNEL_WRITE_CHARS;
|
|
const maxDuplexChannelProtocolErrors =
|
|
options.duplexChannelLimits?.maxProtocolErrors ??
|
|
MAX_DUPLEX_CHANNEL_PROTOCOL_ERRORS;
|
|
const maxDuplexChannelTotalDataBytes =
|
|
options.duplexChannelLimits?.maxTotalDataBytes ??
|
|
MAX_DUPLEX_CHANNEL_TOTAL_DATA_BYTES;
|
|
const maxDuplexChannelDurationMs =
|
|
options.duplexChannelLimits?.maxDurationMs ?? MAX_DUPLEX_CHANNEL_DURATION_MS;
|
|
const duplexChannelOpenTimeoutMs =
|
|
options.duplexChannelLimits?.openTimeoutMs ?? DUPLEX_CHANNEL_OPEN_TIMEOUT_MS;
|
|
const duplexChannelCloseTimeoutMs =
|
|
options.duplexChannelLimits?.closeTimeoutMs ?? DUPLEX_CHANNEL_CLOSE_TIMEOUT_MS;
|
|
|
|
// ------------------------------------------------------------------
|
|
// Proactive company scopes (LOOA-629)
|
|
// ------------------------------------------------------------------
|
|
// A proactive plugin (e.g. the chat gateway) does company-scoped work from
|
|
// its own timers/loops — not inside a host-issued top-level invocation
|
|
// (onEvent/performAction/executeTool/configChanged). Those worker→host calls
|
|
// carry no `paperclipInvocationId`, so the governed-access gate
|
|
// (host-client-factory.ts) rejects any company-scoped request with
|
|
// "company context is required" (regression class from #9557). The host
|
|
// authorizes a bounded set of companies — the plugin's configured companies,
|
|
// set by the loader after startup config delivery — for such proactive work.
|
|
// A no-invocation call that references one of these companies resolves to
|
|
// that company's scope; a call referencing any other company stays denied,
|
|
// and in-invocation calls keep their strict single-company match.
|
|
//
|
|
// Seeded from options at handle creation — before the child process is
|
|
// spawned — so a proactive plugin's setup()-time host calls (which land while
|
|
// `startWorker` is still awaiting initialize) are authorized in time. The
|
|
// loader used to call setProactiveCompanyScopes only AFTER startWorker
|
|
// resolved, which was too late for the gateway's one-shot events.subscribe
|
|
// and left outbound push permanently dead (LOOA-695).
|
|
const proactiveCompanyScopes = new Set<string>();
|
|
for (const id of options.proactiveCompanyScopes ?? []) {
|
|
const trimmed = readNonEmptyString(id);
|
|
if (trimmed) proactiveCompanyScopes.add(trimmed);
|
|
}
|
|
|
|
// Optional methods reported by the worker during initialization
|
|
let supportedMethods: string[] = [];
|
|
|
|
// Crash tracking for exponential backoff
|
|
let consecutiveCrashes = 0;
|
|
let totalCrashes = 0;
|
|
let lastCrashAt: number | null = null;
|
|
let backoffTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let nextRestartAt: number | null = null;
|
|
|
|
// Track open stream channels so we can emit synthetic close on crash.
|
|
// Maps channel → companyId.
|
|
const openStreamChannels = new Map<string, string>();
|
|
|
|
// Shutdown coordination
|
|
let intentionalStop = false;
|
|
|
|
const rpcTimeoutMs = options.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS;
|
|
const autoRestart = options.autoRestart ?? true;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Status management
|
|
// -----------------------------------------------------------------------
|
|
|
|
function setStatus(newStatus: WorkerStatus): void {
|
|
const prev = status;
|
|
if (prev === newStatus) return;
|
|
status = newStatus;
|
|
log.debug({ from: prev, to: newStatus }, "worker status change");
|
|
emitter.emit("status", { pluginId, status: newStatus, previousStatus: prev });
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// JSON-RPC message sending
|
|
// -----------------------------------------------------------------------
|
|
|
|
function sendMessage(message: unknown, meterDuplexWrite = false): void {
|
|
if (!childProcess?.stdin?.writable) {
|
|
throw new Error(`Worker process for plugin "${pluginId}" is not writable`);
|
|
}
|
|
const serialized = serializeMessage(message as any);
|
|
const ledger = duplexAggregateByteLedger;
|
|
if (meterDuplexWrite && ledger) {
|
|
// Charge the child-stdin transport buffer for a duplex write. The host writes
|
|
// the serialized frame to the child stdin without backpressure. When the child
|
|
// stops reading its stdin, the frame stays in the host stdin write buffer.
|
|
// Reserve the exact serialized-frame byte count. That count includes the JSON
|
|
// escaping and the newline framing, so the ledger covers the retained transport
|
|
// bytes, not only the raw payload. The RPC separately holds the raw payload
|
|
// under a `pending_write` token, so the two tokens cover the peak of both
|
|
// retentions at the same time.
|
|
const bytes = Buffer.byteLength(serialized);
|
|
const token = ledger.reserve("stdin_write", bytes);
|
|
if (!token) {
|
|
// The reservation would pass the aggregate ceiling. Fail closed before the
|
|
// enqueue: do not write the frame. The duplex write caller ends the route.
|
|
throw new DuplexAggregateBytesExceededError();
|
|
}
|
|
// Hold the token until the stream flushes the chunk. The write callback fires
|
|
// when the stream hands the chunk to the operating system, so the bytes then
|
|
// leave the host stream buffer. The RPC settle and the RPC timeout never
|
|
// release this token. Only the flush, the stream error, the stream close, or
|
|
// the worker exit releases it. Release through the outstanding-token set, so a
|
|
// later stream-error or worker-exit sweep never double-releases the same token.
|
|
pendingStdinWriteTokens.add(token);
|
|
childProcess.stdin.write(serialized, () => {
|
|
if (pendingStdinWriteTokens.delete(token)) {
|
|
ledger.release(token);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
childProcess.stdin.write(serialized);
|
|
}
|
|
|
|
function errorCodeForWorkerHostError(err: unknown): number {
|
|
const code = (err as { code?: unknown } | null)?.code;
|
|
const pluginErrorCodes: readonly number[] = Object.values(PLUGIN_RPC_ERROR_CODES);
|
|
return typeof code === "number" && pluginErrorCodes.includes(code)
|
|
? code
|
|
: JSONRPC_ERROR_CODES.INTERNAL_ERROR;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Incoming message handling
|
|
// -----------------------------------------------------------------------
|
|
|
|
function handleLine(line: string): void {
|
|
if (!line.trim()) return;
|
|
|
|
// Enforce the framing bound BEFORE the JSON parse. A line longer than the
|
|
// limit is dropped without a parse, so a faulty or hostile worker cannot
|
|
// force the host to parse an unbounded document and exhaust memory.
|
|
if (line.length > maxIncomingMessageChars) {
|
|
dropOversizedLine(line.length);
|
|
return;
|
|
}
|
|
|
|
let message: unknown;
|
|
try {
|
|
message = parseMessage(line);
|
|
} catch (err) {
|
|
if (err instanceof JsonRpcParseError) {
|
|
log.warn({ rawLine: line.slice(0, 200) }, "unparseable message from worker");
|
|
} else {
|
|
log.warn({ err }, "error parsing worker message");
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (isJsonRpcResponse(message)) {
|
|
handleResponse(message);
|
|
} else if (isJsonRpcRequest(message)) {
|
|
handleWorkerRequest(message as JsonRpcRequest);
|
|
} else if (isJsonRpcNotification(message)) {
|
|
handleWorkerNotification(message as JsonRpcNotification);
|
|
} else {
|
|
log.warn("unknown message type from worker");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle a JSON-RPC response from the worker (matching a pending request).
|
|
*/
|
|
function handleResponse(response: JsonRpcResponse): void {
|
|
const id = response.id;
|
|
if (id === null || id === undefined) {
|
|
log.warn("received response with null/undefined id");
|
|
return;
|
|
}
|
|
|
|
const pending = pendingRequests.get(id);
|
|
if (!pending) {
|
|
log.warn({ id }, "received response for unknown request id");
|
|
return;
|
|
}
|
|
|
|
clearTimeout(pending.timer);
|
|
pendingRequests.delete(id);
|
|
pending.resolve(response);
|
|
}
|
|
|
|
function readNonEmptyString(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function deriveInvocationScope(
|
|
method: HostToWorkerMethodName | string,
|
|
params: unknown,
|
|
): PluginInvocationScope | null {
|
|
if (!isRecord(params)) return null;
|
|
|
|
const directCompanyId = readNonEmptyString(params.companyId);
|
|
if (directCompanyId) return { companyId: directCompanyId };
|
|
|
|
if (method === "performAction" && isRecord(params.actorContext)) {
|
|
const companyId = readNonEmptyString(params.actorContext.companyId);
|
|
return companyId ? { companyId } : null;
|
|
}
|
|
|
|
if (method === "executeTool" && isRecord(params.runContext)) {
|
|
const companyId = readNonEmptyString(params.runContext.companyId);
|
|
return companyId ? { companyId } : null;
|
|
}
|
|
|
|
if (method === "onEvent" && isRecord(params.event)) {
|
|
const companyId = readNonEmptyString(params.event.companyId);
|
|
return companyId ? { companyId } : null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function registerInvocation(scope: PluginInvocationScope, ttlMs?: number): PluginInvocationContext {
|
|
// Mint a W3C `traceparent` from the active startup span, so the worker's
|
|
// provider span can parent to it. The host keeps the value on its own record
|
|
// (below) and never trusts the worker to supply the parent. Outside a
|
|
// measured startup step there is no active span, so this is undefined.
|
|
const activeStep = getActiveStepContext();
|
|
const traceparent = activeStep
|
|
? traceparentFromContextToken(activeStep.parentContext)
|
|
: undefined;
|
|
const invocation: PluginInvocationContext = {
|
|
id: randomUUID(),
|
|
scope,
|
|
...(traceparent ? { traceparent } : {}),
|
|
};
|
|
const entry: ActiveInvocation = { scope, traceparent };
|
|
if (ttlMs !== undefined) {
|
|
entry.timer = setTimeout(() => {
|
|
activeInvocations.delete(invocation.id);
|
|
}, ttlMs);
|
|
if (entry.timer.unref) entry.timer.unref();
|
|
}
|
|
activeInvocations.set(invocation.id, entry);
|
|
return invocation;
|
|
}
|
|
|
|
function clearInvocation(invocation: PluginInvocationContext | null): void {
|
|
if (!invocation) return;
|
|
const entry = activeInvocations.get(invocation.id);
|
|
if (entry?.timer) clearTimeout(entry.timer);
|
|
activeInvocations.delete(invocation.id);
|
|
}
|
|
|
|
// Store the host-owned execute route for one active execute call. The host
|
|
// holds the exact company id and log sink; the worker never supplies them.
|
|
function registerExecuteRoute(
|
|
invocationId: string,
|
|
companyId: string,
|
|
onLog: ExecuteLogSink,
|
|
): void {
|
|
activeExecuteRoutes.set(invocationId, {
|
|
companyId,
|
|
onLog,
|
|
deliveredChars: 0,
|
|
crossCompanyBlocked: false,
|
|
});
|
|
}
|
|
|
|
function clearExecuteRoute(invocationId: string | undefined): void {
|
|
if (invocationId) activeExecuteRoutes.delete(invocationId);
|
|
}
|
|
|
|
// Drop an oversized incoming worker line before the JSON parse. Write a
|
|
// rate-limited warn record with the length and a running drop count. The
|
|
// record never carries the line bytes.
|
|
function dropOversizedLine(lineLength: number): void {
|
|
oversizedLineDropCount += 1;
|
|
const nowMs = Date.now();
|
|
if (nowMs - oversizedLineLoggedAtMs >= EXECUTE_LOG_DROP_LOG_INTERVAL_MS) {
|
|
log.warn(
|
|
{ lineLength, maxIncomingMessageChars, droppedSinceLastLog: oversizedLineDropCount },
|
|
"dropping oversized worker line before JSON parse",
|
|
);
|
|
oversizedLineLoggedAtMs = nowMs;
|
|
oversizedLineDropCount = 0;
|
|
}
|
|
}
|
|
|
|
// Drop an `execute.log` notification. Write a rate-limited debug record with
|
|
// the reason and a running drop count. The record never carries the chunk
|
|
// bytes, the company id, or command data.
|
|
function dropExecuteLogNotification(reason: string): void {
|
|
executeLogDropCount += 1;
|
|
const nowMs = Date.now();
|
|
if (nowMs - executeLogDropLoggedAtMs >= EXECUTE_LOG_DROP_LOG_INTERVAL_MS) {
|
|
log.debug(
|
|
{ reason, droppedSinceLastLog: executeLogDropCount },
|
|
"dropping execute.log notification",
|
|
);
|
|
executeLogDropLoggedAtMs = nowMs;
|
|
executeLogDropCount = 0;
|
|
}
|
|
}
|
|
|
|
// Route one `execute.log` notification to its host-owned execute route. The
|
|
// route is the single authority for the delivery target and the company
|
|
// binding. This never reads a company id from the notification and never
|
|
// routes through the generic active-invocation record.
|
|
//
|
|
// Complete mediation: the host and the worker share one stdio pipe, and the
|
|
// worker process sees every active invocation id. So the host cannot prove
|
|
// which concurrent invocation produced a notification, and it must NOT treat
|
|
// the worker-supplied `paperclipInvocationId` alone as proof of origin. The
|
|
// host validates the exact company scope instead: it delivers only while every
|
|
// active execute route on this worker belongs to ONE company. When a second
|
|
// company's execute overlaps, the host fails closed — it latches the active
|
|
// routes and drops the chunk — so a worker that runs company A can never forge
|
|
// company B's active id and inject output into B's route. The final command
|
|
// result still delivers the complete output, so no byte is lost; only the live
|
|
// stream pauses while two companies overlap.
|
|
function routeExecuteLogNotification(notification: JsonRpcNotification): void {
|
|
const invocationId = readNonEmptyString(
|
|
(notification as { paperclipInvocationId?: unknown }).paperclipInvocationId,
|
|
);
|
|
const params = isRecord(notification.params) ? notification.params : {};
|
|
const stream = params.stream;
|
|
const chunk = params.chunk;
|
|
// Runtime-validate the payload. Drop invalid input without a throw.
|
|
if (stream !== "stdout" && stream !== "stderr") {
|
|
dropExecuteLogNotification("invalid-stream");
|
|
return;
|
|
}
|
|
if (
|
|
typeof chunk !== "string" ||
|
|
chunk.length === 0 ||
|
|
chunk.length > MAX_EXECUTE_LOG_CHUNK_CHARS
|
|
) {
|
|
dropExecuteLogNotification("invalid-chunk");
|
|
return;
|
|
}
|
|
if (!invocationId) {
|
|
dropExecuteLogNotification("missing-invocation");
|
|
return;
|
|
}
|
|
const route = activeExecuteRoutes.get(invocationId);
|
|
if (!route) {
|
|
// No active execute route for this id: a late chunk after settlement or
|
|
// timeout, a non-execute invocation, or an unknown id. Drop it.
|
|
dropExecuteLogNotification("no-active-route");
|
|
return;
|
|
}
|
|
// The route already lost single-company attribution earlier in its life, so
|
|
// it stays closed for the rest of the call.
|
|
if (route.crossCompanyBlocked) {
|
|
dropExecuteLogNotification("cross-company-scope");
|
|
return;
|
|
}
|
|
// Validate the exact company scope. Deliver only while every active execute
|
|
// route on this worker belongs to one company. A second company's active
|
|
// route makes the shared pipe ambiguous, so the host fails closed: it
|
|
// latches every active route and drops the chunk.
|
|
let onlyCompanyId: string | null = null;
|
|
let crossCompany = false;
|
|
for (const active of activeExecuteRoutes.values()) {
|
|
if (onlyCompanyId === null) {
|
|
onlyCompanyId = active.companyId;
|
|
} else if (onlyCompanyId !== active.companyId) {
|
|
crossCompany = true;
|
|
break;
|
|
}
|
|
}
|
|
if (crossCompany) {
|
|
for (const active of activeExecuteRoutes.values()) {
|
|
active.crossCompanyBlocked = true;
|
|
}
|
|
dropExecuteLogNotification("cross-company-scope");
|
|
return;
|
|
}
|
|
// Bound the total characters one execute call may stream. Past the ceiling
|
|
// the host drops further chunks, so one runaway or hostile execution cannot
|
|
// flood the host and the run-log sink without limit.
|
|
if (route.deliveredChars + chunk.length > maxExecuteLogTotalChars) {
|
|
dropExecuteLogNotification("execute-output-cap");
|
|
return;
|
|
}
|
|
route.deliveredChars += chunk.length;
|
|
try {
|
|
const delivery = route.onLog(stream, chunk);
|
|
if (delivery && typeof (delivery as Promise<void>).then === "function") {
|
|
void (delivery as Promise<void>).catch((err) => {
|
|
log.error(
|
|
{ err: err instanceof Error ? err.message : String(err) },
|
|
"execute.log delivery failed",
|
|
);
|
|
});
|
|
}
|
|
} catch (err) {
|
|
log.error(
|
|
{ err: err instanceof Error ? err.message : String(err) },
|
|
"execute.log delivery threw",
|
|
);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Host-owned setup-token login pseudo-terminal route gate
|
|
// -----------------------------------------------------------------------
|
|
// The manager owns one live login pseudo-terminal route per worker. It mints a
|
|
// host-owned opaque route identifier, carries it in the open call, and keys the
|
|
// close on it, so it closes a worker-created terminal even when the open reply
|
|
// was lost and no worker session identifier arrived. It binds the worker
|
|
// session identifier one time while the route is `opening`, for output only. It
|
|
// never trusts a worker-supplied identifier as proof of origin: it delivers
|
|
// output only while the route is `open` and the notification carries the exact
|
|
// bound identifier and valid bounded bytes, and it never logs the raw bytes. It
|
|
// terminalizes the route exactly once on every open failure path, closes the
|
|
// terminal by the host route identifier, and admits a new open only after it
|
|
// verifies a close acknowledgement bound to that identifier; it retires the
|
|
// worker on an unconfirmed close.
|
|
|
|
// A single-consumer route state. The login pseudo-terminal route and the
|
|
// generic duplex channel route share it.
|
|
type RouteState = "reserved" | "opening" | "open" | "closed";
|
|
|
|
// Shared route-binding helpers. The login pseudo-terminal route and the duplex
|
|
// channel route both use them, so the two routes bind and settle one way.
|
|
|
|
// Settle the route wait exactly once. Replace the settler with a no-op, so a
|
|
// later exit or terminalize never settles the wait a second time.
|
|
function settleRouteWait(
|
|
route: { settleWait: (value: { exitCode: number | null; transportClosed?: boolean }) => void },
|
|
value: { exitCode: number | null; transportClosed?: boolean },
|
|
): void {
|
|
const settle = route.settleWait;
|
|
route.settleWait = () => {};
|
|
settle(value);
|
|
}
|
|
|
|
// Read the worker session identifier from an open reply, but only when the
|
|
// route can still bind. Return null for a malformed reply, or for a route that
|
|
// already left `opening` or terminalized. A late or a duplicate reply never
|
|
// binds, revives, or reopens a route.
|
|
function readBindableWorkerSessionId(
|
|
route: { state: RouteState; terminalized: boolean },
|
|
openResult: unknown,
|
|
): string | null {
|
|
const workerSessionId = readNonEmptyString(
|
|
isRecord(openResult) ? openResult.workerSessionId : null,
|
|
);
|
|
if (!workerSessionId || route.state !== "opening" || route.terminalized) {
|
|
return null;
|
|
}
|
|
return workerSessionId;
|
|
}
|
|
|
|
type SetupTokenPtyRouteState = RouteState;
|
|
interface SetupTokenPtyRoute {
|
|
hostRouteId: string;
|
|
state: SetupTokenPtyRouteState;
|
|
workerSessionId: string | null;
|
|
listener: ((chunk: string) => void) | null;
|
|
buffered: string[];
|
|
deliveredChars: number;
|
|
terminalized: boolean;
|
|
settleWait: (value: { exitCode: number | null }) => void;
|
|
}
|
|
// At most one active credential pseudo-terminal per worker. A non-null route
|
|
// blocks a second open until the manager confirms the first route's close.
|
|
let setupTokenPtyRoute: SetupTokenPtyRoute | null = null;
|
|
|
|
// Close the worker terminal by the host route identifier and verify the bound
|
|
// acknowledgement. Return true only when the worker returns an acknowledgement
|
|
// that carries the exact host route identifier. An absent, malformed,
|
|
// mismatched, or timed-out acknowledgement returns false, so the caller fails
|
|
// closed.
|
|
async function closeSetupTokenPtyTerminal(hostRouteId: string): Promise<boolean> {
|
|
try {
|
|
const ack = await callInternal(
|
|
"setupTokenPtyClose",
|
|
{ hostRouteId },
|
|
setupTokenPtyCloseTimeoutMs,
|
|
);
|
|
return isRecord(ack) && readNonEmptyString(ack.hostRouteId) === hostRouteId;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Terminalize the route exactly once. Resolve the login wait, close the worker
|
|
// terminal by the host route identifier, and free the per-worker slot only
|
|
// after the close resolves. Retire the worker when the close is unconfirmed.
|
|
async function terminalizeSetupTokenPtyRoute(route: SetupTokenPtyRoute): Promise<void> {
|
|
if (route.terminalized) return;
|
|
route.terminalized = true;
|
|
route.state = "closed";
|
|
route.listener = null;
|
|
route.buffered = [];
|
|
// A terminalized route reports a null exit code, which the runner treats as a
|
|
// failure.
|
|
settleRouteWait(route, { exitCode: null });
|
|
const confirmed = await closeSetupTokenPtyTerminal(route.hostRouteId);
|
|
if (setupTokenPtyRoute === route) setupTokenPtyRoute = null;
|
|
if (!confirmed) {
|
|
// The worker did not acknowledge the close, so the host cannot prove the
|
|
// terminal is gone. Fail closed: retire the worker before any reuse.
|
|
log.error(
|
|
{ pluginId },
|
|
"setup-token login pseudo-terminal close not acknowledged; retiring worker",
|
|
);
|
|
void killProcess();
|
|
}
|
|
}
|
|
|
|
// Route one login pseudo-terminal output notification to the per-session
|
|
// listener. Deliver only while the route is `open` and the notification carries
|
|
// the exact bound worker session identifier and valid bounded bytes. Drop an
|
|
// unknown, late, malformed, or mismatched notification. Never log the raw bytes.
|
|
function routeSetupTokenPtyOutput(notification: JsonRpcNotification): void {
|
|
const route = setupTokenPtyRoute;
|
|
if (!route || route.state !== "open") return;
|
|
const params = isRecord(notification.params) ? notification.params : {};
|
|
const workerSessionId = readNonEmptyString(params.workerSessionId);
|
|
if (!workerSessionId || workerSessionId !== route.workerSessionId) return;
|
|
const chunk = params.chunk;
|
|
if (
|
|
typeof chunk !== "string" ||
|
|
chunk.length === 0 ||
|
|
chunk.length > maxSetupTokenPtyChunkChars
|
|
) {
|
|
return;
|
|
}
|
|
if (route.deliveredChars + chunk.length > maxSetupTokenPtyTotalChars) {
|
|
// The cumulative output passed the per-route bound. Terminalize the route.
|
|
void terminalizeSetupTokenPtyRoute(route);
|
|
return;
|
|
}
|
|
route.deliveredChars += chunk.length;
|
|
if (route.listener) route.listener(chunk);
|
|
else route.buffered.push(chunk);
|
|
}
|
|
|
|
// Route one login pseudo-terminal exit notification to the login wait. Resolve
|
|
// only while the route is `open` and the notification carries the exact bound
|
|
// worker session identifier.
|
|
function routeSetupTokenPtyExit(notification: JsonRpcNotification): void {
|
|
const route = setupTokenPtyRoute;
|
|
if (!route || route.state !== "open") return;
|
|
const params = isRecord(notification.params) ? notification.params : {};
|
|
const workerSessionId = readNonEmptyString(params.workerSessionId);
|
|
if (!workerSessionId || workerSessionId !== route.workerSessionId) return;
|
|
const exitCode = typeof params.exitCode === "number" ? params.exitCode : null;
|
|
settleRouteWait(route, { exitCode });
|
|
}
|
|
|
|
// Close the one route on a worker exit. The worker is gone, so the manager
|
|
// resolves the login wait with the fixed non-secret exit and clears the route
|
|
// one time. The pending pseudo-terminal calls reject through `rejectAllPending`.
|
|
function closeSetupTokenPtyRouteOnWorkerExit(): void {
|
|
const route = setupTokenPtyRoute;
|
|
if (!route) return;
|
|
setupTokenPtyRoute = null;
|
|
route.terminalized = true;
|
|
route.state = "closed";
|
|
route.listener = null;
|
|
route.buffered = [];
|
|
settleRouteWait(route, { exitCode: null });
|
|
}
|
|
|
|
// Open one live login pseudo-terminal route. Reserve the route
|
|
// before the open call, bind the worker session identifier one time on the
|
|
// first successful open reply, and return a session the login transport drives.
|
|
// Terminalize the route on every open failure path.
|
|
async function openSetupTokenPtySession(
|
|
input: SetupTokenPtyOpenInput,
|
|
): Promise<SetupTokenPtyHostSession> {
|
|
if (input.command !== CLAUDE_SETUP_TOKEN_COMMAND) {
|
|
// Allowlist the login command. Only the fixed `CLAUDE_SETUP_TOKEN_COMMAND`
|
|
// may run in the sandbox pseudo-terminal. Reject any other command with one
|
|
// fixed non-secret error before the worker call, so a caller cannot spawn
|
|
// an arbitrary process in the sandbox pseudo-terminal.
|
|
throw new Error(SETUP_TOKEN_PTY_COMMAND_NOT_ALLOWED);
|
|
}
|
|
if (setupTokenPtyRoute) {
|
|
// A route for this worker is not yet closed and confirmed. Reject the
|
|
// second open with one fixed non-secret error before it reaches the worker.
|
|
throw new Error(SETUP_TOKEN_PTY_ROUTE_BUSY);
|
|
}
|
|
const hostRouteId = randomUUID();
|
|
let settleWait: (value: { exitCode: number | null }) => void = () => {};
|
|
const waitPromise = new Promise<{ exitCode: number | null }>((resolve) => {
|
|
settleWait = resolve;
|
|
});
|
|
const route: SetupTokenPtyRoute = {
|
|
hostRouteId,
|
|
state: "reserved",
|
|
workerSessionId: null,
|
|
listener: null,
|
|
buffered: [],
|
|
deliveredChars: 0,
|
|
terminalized: false,
|
|
settleWait,
|
|
};
|
|
setupTokenPtyRoute = route;
|
|
|
|
route.state = "opening";
|
|
let openResult: HostToWorkerMethods["setupTokenPtyOpen"][1];
|
|
try {
|
|
openResult = await callInternal(
|
|
"setupTokenPtyOpen",
|
|
{
|
|
hostRouteId,
|
|
driverKey: input.driverKey,
|
|
companyId: input.companyId,
|
|
environmentId: input.environmentId,
|
|
providerLeaseId: input.providerLeaseId,
|
|
command: input.command,
|
|
},
|
|
setupTokenPtyOpenTimeoutMs,
|
|
);
|
|
} catch (err) {
|
|
// A send failure, an RPC rejection, or an open timeout. Terminalize the
|
|
// route exactly once and fail closed.
|
|
await terminalizeSetupTokenPtyRoute(route);
|
|
throw err instanceof Error ? err : new Error(SETUP_TOKEN_PTY_OPEN_FAILED);
|
|
}
|
|
|
|
const workerSessionId = readBindableWorkerSessionId(route, openResult);
|
|
if (!workerSessionId) {
|
|
// A malformed reply, or a route that already left `opening`. A late or a
|
|
// duplicate reply never binds, revives, or reopens a route.
|
|
await terminalizeSetupTokenPtyRoute(route);
|
|
throw new Error(SETUP_TOKEN_PTY_OPEN_FAILED);
|
|
}
|
|
// Bind the worker session identifier one time and move the route to `open`.
|
|
route.workerSessionId = workerSessionId;
|
|
route.state = "open";
|
|
|
|
return {
|
|
onData(listener: (chunk: string) => void): void {
|
|
route.listener = listener;
|
|
if (route.buffered.length > 0) {
|
|
const pending = route.buffered;
|
|
route.buffered = [];
|
|
for (const chunk of pending) listener(chunk);
|
|
}
|
|
},
|
|
write(data: string): void {
|
|
const sid = route.workerSessionId;
|
|
if (route.state !== "open" || !sid) return;
|
|
void callInternal(
|
|
"setupTokenPtyInput",
|
|
{ workerSessionId: sid, data },
|
|
setupTokenPtyOpenTimeoutMs,
|
|
).catch(() => {});
|
|
},
|
|
wait(): Promise<{ exitCode: number | null }> {
|
|
return waitPromise;
|
|
},
|
|
kill(): void {
|
|
const sid = route.workerSessionId;
|
|
if (!sid) return;
|
|
void callInternal(
|
|
"setupTokenPtyStop",
|
|
{ workerSessionId: sid },
|
|
setupTokenPtyOpenTimeoutMs,
|
|
).catch(() => {});
|
|
},
|
|
async close(): Promise<void> {
|
|
await terminalizeSetupTokenPtyRoute(route);
|
|
},
|
|
};
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Host-owned generic duplex channel route
|
|
// -----------------------------------------------------------------------
|
|
// The duplex channel route mirrors the login pseudo-terminal route model. The
|
|
// host owns the route identifier, binds the worker session identifier one time
|
|
// on a valid open reply, and keys the close on the host route identifier. It
|
|
// rejects a late or a duplicate open reply, and it retires the worker on an
|
|
// unconfirmed close. The duplex channel carries no command allowlist, so the
|
|
// caller owns the command.
|
|
//
|
|
// The route adds seven explicit bounds the pseudo-terminal route lacks. Each
|
|
// bound ends the route when it passes its limit:
|
|
// 1. pre-bind buffered bytes — the cumulative characters the host buffers
|
|
// before a data listener attaches;
|
|
// 2. pre-bind buffered frame count — the number of data frames the host
|
|
// buffers before a data listener attaches;
|
|
// 3. pending request count — the number of in-flight host→worker requests;
|
|
// 4. host→worker write size — the characters for one write;
|
|
// 5. protocol error rate — the count of malformed or mismatched data frames;
|
|
// 6. total data bytes — the cumulative inbound bytes over the whole life,
|
|
// counted before and after a data listener attaches;
|
|
// 7. route lifetime — the milliseconds from the open to the terminal end.
|
|
|
|
// One buffered data chunk retained for a late listener drain. It carries the raw
|
|
// chunk string and the aggregate byte token that reserved its raw bytes. The
|
|
// token is `null` when no ledger is injected.
|
|
interface BufferedDuplexChunk {
|
|
chunk: string;
|
|
token: ReservationToken | null;
|
|
}
|
|
// One pre-bind data event, normalized to the narrow duplex-event schema. The host
|
|
// retains only these bounded scalar fields plus the reservation token, never the
|
|
// original arbitrary notification graph. The bind re-resolves the pair from
|
|
// `workerSessionId`, so a frame whose pair does not match the bound pair still
|
|
// fails closed.
|
|
interface HeldDuplexEvent {
|
|
workerSessionId: string;
|
|
chunk: string;
|
|
token: ReservationToken | null;
|
|
}
|
|
// One pre-bind exit event, normalized to the narrow duplex-event schema.
|
|
interface HeldDuplexExitEvent {
|
|
workerSessionId: string;
|
|
exitCode: number | null;
|
|
token: ReservationToken | null;
|
|
}
|
|
|
|
interface DuplexChannelRoute {
|
|
hostRouteId: string;
|
|
state: RouteState;
|
|
workerSessionId: string | null;
|
|
listener: ((chunk: string) => void) | null;
|
|
buffered: BufferedDuplexChunk[];
|
|
bufferedChars: number;
|
|
pendingRequests: number;
|
|
protocolErrors: number;
|
|
totalDataBytes: number;
|
|
lifetimeTimer: ReturnType<typeof setTimeout> | null;
|
|
terminalized: boolean;
|
|
settleWait: (value: { exitCode: number | null; transportClosed?: boolean }) => void;
|
|
// The bounded raw data events that arrived before the bind, held in order. The
|
|
// bind replays them through the exact-pair routing, so an early frame is never
|
|
// lost and a frame whose pair does not match the bound pair still fails closed.
|
|
// The hold ceiling is `maxDuplexChannelPreBindHoldFrames`, one frame above the
|
|
// buffered bound, so the replay's buffered-bound check ends the route, not the
|
|
// hold. Each held event carries the aggregate byte token that reserved its raw
|
|
// bytes; the host never retains the original arbitrary notification graph.
|
|
preBind: HeldDuplexEvent[];
|
|
// The single bounded exit event that arrived before the bind. An exit never
|
|
// consumes a data hold slot, so a worker that batches an exit among enough data
|
|
// frames to fill the hold cannot crowd out a data frame. The bind replays the
|
|
// held data events first, then this exit last.
|
|
preBindExit: HeldDuplexExitEvent | null;
|
|
// The authoritative registry of every aggregate byte token this route still
|
|
// holds, across the pre-bind, buffered, and terminal-buffered representations.
|
|
// The byte cleanup releases every token here exactly once.
|
|
retainedTokens: Set<ReservationToken>;
|
|
}
|
|
// The live duplex routes on this worker, keyed by the exact
|
|
// `{ hostRouteId, workerSessionId }` pair. The host binds one pair once, at
|
|
// open, and routes each data, exit, and close frame only to the route that owns
|
|
// its exact pair. The host never routes by the worker session id alone.
|
|
const liveDuplexRoutes = new Map<string, DuplexChannelRoute>();
|
|
// The reserved-or-opening routes, keyed by the host route id. A route lives here
|
|
// from the open call until the worker session id binds. The host then moves it
|
|
// to `liveDuplexRoutes` under the exact pair key.
|
|
const openingDuplexRoutes = new Map<string, DuplexChannelRoute>();
|
|
// The tombstoned pairs on this worker, keyed by the exact pair key. The host
|
|
// installs a tombstone atomically when it removes a live binding, and retains it
|
|
// until the worker retires. A late frame for a tombstoned pair reaches no
|
|
// listener. A closed pair never returns.
|
|
const duplexPairTombstones = new Set<string>();
|
|
|
|
// The process-scoped aggregate route-slot controller. The manager injects it, so
|
|
// the ceiling counts every duplex route across every worker in the process, not
|
|
// one agent's setting. When it is absent, the worker admits every open, so the
|
|
// handle runs unbounded in isolation (a unit test constructs it this way).
|
|
const duplexRouteSlots = options.duplexRouteSlots ?? null;
|
|
// The routes that currently hold one aggregate slot. The host releases a slot one
|
|
// time per route, so a double terminalize never releases two slots.
|
|
const duplexRouteSlotHolders = new Set<DuplexChannelRoute>();
|
|
|
|
// The process-owned aggregate byte ledger. The manager injects it, so one gauge
|
|
// bounds the aggregate retained bytes across every route in the process. When it
|
|
// is absent, the worker retains duplex bytes unbounded (a unit test constructs it
|
|
// this way).
|
|
const duplexAggregateByteLedger = options.duplexAggregateByteLedger ?? null;
|
|
// The outstanding child-stdin transport tokens for duplex writes. Each token
|
|
// covers one serialized frame the host stdin write buffer still retains. The
|
|
// write callback releases a token on the flush; a stream error, a stream close,
|
|
// or a worker exit releases every remaining token, because each of those
|
|
// discards the stdin write buffer. The set is the release guard, so a token
|
|
// releases one time across the two paths.
|
|
const pendingStdinWriteTokens = new Set<ReservationToken>();
|
|
// Release every outstanding child-stdin transport token. A stream error, a
|
|
// stream close, or a worker exit calls this, because each discards the stdin
|
|
// write buffer. The `delete` guard drops each token one time, so a later flush
|
|
// callback or a second sweep releases nothing again.
|
|
function releaseAllPendingStdinWriteTokens(): void {
|
|
if (!duplexAggregateByteLedger) return;
|
|
for (const token of pendingStdinWriteTokens) {
|
|
duplexAggregateByteLedger.release(token);
|
|
}
|
|
pendingStdinWriteTokens.clear();
|
|
}
|
|
// The terminalized routes that still hold buffered bytes for a late listener
|
|
// drain. A terminalized route leaves the opening and live maps, so this registry
|
|
// keeps the worker-exit sweep able to release its still-charged buffered tokens.
|
|
const terminalDuplexRoutes = new Set<DuplexChannelRoute>();
|
|
|
|
// Reserve `bytes` for one route retention against the aggregate ledger. Return
|
|
// the held token, or `null` when the reservation would pass the ceiling. When no
|
|
// ledger is present, return `"no-ledger"`, so the caller admits the retention
|
|
// with no token. The helper adds a real token to `route.retainedTokens`.
|
|
function reserveRouteBytes(
|
|
route: DuplexChannelRoute,
|
|
owner: DuplexAggregateTokenOwner,
|
|
bytes: number,
|
|
): ReservationToken | "no-ledger" | null {
|
|
if (!duplexAggregateByteLedger) return "no-ledger";
|
|
const token = duplexAggregateByteLedger.reserve(owner, bytes);
|
|
if (!token) return null;
|
|
route.retainedTokens.add(token);
|
|
return token;
|
|
}
|
|
|
|
// Transfer a held route token to a new owner label. The token identity and the
|
|
// reserved bytes stay the same, so no admission gap opens between two
|
|
// representations of the same retained bytes.
|
|
function transferRouteToken(
|
|
token: ReservationToken | null,
|
|
owner: DuplexAggregateTokenOwner,
|
|
): void {
|
|
if (!token) return;
|
|
duplexAggregateByteLedger?.transfer(token, owner);
|
|
}
|
|
|
|
// Release one held route token and drop it from the route registry, in one
|
|
// synchronous step. A `null` token (no ledger) releases nothing.
|
|
function releaseRouteToken(route: DuplexChannelRoute, token: ReservationToken | null): void {
|
|
if (!token) return;
|
|
route.retainedTokens.delete(token);
|
|
duplexAggregateByteLedger?.release(token);
|
|
}
|
|
|
|
// Release every token a route still holds and clear its retained representations,
|
|
// exactly once. A later call finds an empty registry and releases nothing, so the
|
|
// helper is idempotent. Every terminal-discard, open-failure, bind-replay
|
|
// failure, close-acknowledgement failure, worker-exit, and shutdown path calls
|
|
// it. Terminal map deletion is never treated as proof that retained bytes are
|
|
// gone; this registry is authoritative for byte cleanup.
|
|
function discardRouteRetained(route: DuplexChannelRoute): void {
|
|
if (duplexAggregateByteLedger) {
|
|
for (const token of route.retainedTokens) {
|
|
duplexAggregateByteLedger.release(token);
|
|
}
|
|
}
|
|
route.retainedTokens.clear();
|
|
route.preBind = [];
|
|
route.preBindExit = null;
|
|
route.buffered = [];
|
|
route.bufferedChars = 0;
|
|
terminalDuplexRoutes.delete(route);
|
|
}
|
|
|
|
// Try to reserve one aggregate route slot for a route. Return true when the
|
|
// route holds a slot after the call. When no controller is present, the route
|
|
// always holds a slot.
|
|
function acquireDuplexRouteSlot(route: DuplexChannelRoute): boolean {
|
|
if (!duplexRouteSlots) return true;
|
|
if (!duplexRouteSlots.tryAcquire()) return false;
|
|
duplexRouteSlotHolders.add(route);
|
|
return true;
|
|
}
|
|
|
|
// Release the aggregate route slot a route holds, one time. A route that never
|
|
// held a slot releases nothing.
|
|
function releaseDuplexRouteSlot(route: DuplexChannelRoute): void {
|
|
if (!duplexRouteSlotHolders.delete(route)) return;
|
|
duplexRouteSlots?.release();
|
|
}
|
|
|
|
// Retire the worker at once. The host kills the process, so every live route,
|
|
// every reserved route, and every tombstone drops together. The host uses this
|
|
// as the fail-closed response to an ownership violation and to a tombstone-set
|
|
// overflow. The host never logs the raw frame content on a violation.
|
|
function retireDuplexWorkerOnViolation(reason: string): void {
|
|
log.error({ pluginId }, `duplex channel ownership violation (${reason}); retiring worker`);
|
|
void killProcess();
|
|
}
|
|
|
|
// Install a tombstone for a bound pair atomically with the removal of its live
|
|
// binding. This runs in one synchronous step, before any slot release or reuse,
|
|
// so a late frame for the pair reaches no listener. Before the set would exceed
|
|
// its hard bound, retire the worker, which drops every route and every tombstone
|
|
// at once. A tombstone overflow fails closed; the host never evicts a tombstone
|
|
// and lets a pair return.
|
|
function tombstoneBoundDuplexPair(route: DuplexChannelRoute): void {
|
|
if (route.workerSessionId === null) return;
|
|
const pairKey = duplexPairKey(route.hostRouteId, route.workerSessionId);
|
|
liveDuplexRoutes.delete(pairKey);
|
|
if (!duplexPairTombstones.has(pairKey)) {
|
|
if (duplexPairTombstones.size >= MAX_DUPLEX_ROUTE_TOMBSTONES) {
|
|
// The set is full. Do not evict a tombstone. Retire the worker, which
|
|
// drops every route and every tombstone together, so no closed pair ever
|
|
// returns.
|
|
retireDuplexWorkerOnViolation("tombstone set overflow");
|
|
return;
|
|
}
|
|
duplexPairTombstones.add(pairKey);
|
|
}
|
|
}
|
|
|
|
// Close the worker channel by the host route identifier and verify the bound
|
|
// acknowledgement. Return true only when the worker returns an acknowledgement
|
|
// for the exact pair. Before a session binds, the acknowledgement carries the
|
|
// host route identifier only, so a lost open reply still permits a route-only
|
|
// close. After a session binds, the acknowledgement must echo both the host
|
|
// route identifier and the bound worker session identifier. An absent,
|
|
// malformed, mismatched, or timed-out acknowledgement returns false, so the
|
|
// caller fails closed.
|
|
async function closeDuplexChannelTerminal(
|
|
hostRouteId: string,
|
|
boundWorkerSessionId: string | null,
|
|
): Promise<boolean> {
|
|
try {
|
|
const ack = await callInternal(
|
|
"duplexChannelClose",
|
|
{ hostRouteId },
|
|
duplexChannelCloseTimeoutMs,
|
|
);
|
|
if (!isRecord(ack) || readNonEmptyString(ack.hostRouteId) !== hostRouteId) {
|
|
return false;
|
|
}
|
|
if (boundWorkerSessionId !== null) {
|
|
// A bound close: the acknowledgement must echo the exact worker session
|
|
// identifier. A missing or a mismatched identifier is invalid.
|
|
return readNonEmptyString(ack.workerSessionId) === boundWorkerSessionId;
|
|
}
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Terminalize the route exactly once. Resolve the wait, close the worker
|
|
// channel by the host route identifier, and free the per-worker slot only
|
|
// after the close resolves. Retire the worker when the close is unconfirmed.
|
|
// Clear the route lifetime timer one time. Every terminal path and the
|
|
// worker-exit path calls this, so a timer never fires after the route ends.
|
|
function clearDuplexChannelLifetimeTimer(route: DuplexChannelRoute): void {
|
|
if (route.lifetimeTimer) {
|
|
clearTimeout(route.lifetimeTimer);
|
|
route.lifetimeTimer = null;
|
|
}
|
|
}
|
|
|
|
async function terminalizeDuplexChannelRoute(route: DuplexChannelRoute): Promise<void> {
|
|
if (route.terminalized) return;
|
|
route.terminalized = true;
|
|
route.state = "closed";
|
|
route.listener = null;
|
|
// Stop admission first. Then move the byte cleanup. Keep the buffered chunks the
|
|
// host accepted before the route ended, so a listener that attaches after the
|
|
// end still drains them. A frame can end the route during the pre-bind replay,
|
|
// before a listener attaches, and the chunks the host accepted before that frame
|
|
// are valid data the listener must still receive. The buffered bytes stay
|
|
// bounded by the pre-bind buffered bound, and `onData` clears and releases them
|
|
// once it drains them.
|
|
//
|
|
// The buffered records keep their tokens; move each token to the terminal owner
|
|
// label and register the route in the terminal registry, so the worker-exit
|
|
// sweep can release the still-charged bytes later. Do not release a buffered
|
|
// token at map deletion; terminal map deletion is never proof the bytes are
|
|
// gone. Release every other token the route still holds (the pre-bind events,
|
|
// the held exit, and any stranded token), because they never reach a listener.
|
|
const bufferedTokens = new Set<ReservationToken>();
|
|
for (const record of route.buffered) {
|
|
if (record.token) {
|
|
bufferedTokens.add(record.token);
|
|
transferRouteToken(record.token, "terminal_buffered");
|
|
}
|
|
}
|
|
for (const token of [...route.retainedTokens]) {
|
|
if (!bufferedTokens.has(token)) {
|
|
route.retainedTokens.delete(token);
|
|
duplexAggregateByteLedger?.release(token);
|
|
}
|
|
}
|
|
route.preBind = [];
|
|
route.preBindExit = null;
|
|
if (route.buffered.length > 0) {
|
|
terminalDuplexRoutes.add(route);
|
|
}
|
|
clearDuplexChannelLifetimeTimer(route);
|
|
// Remove the live binding and install the tombstone in one synchronous step,
|
|
// before the worker close and before any reuse. A reserved route that never
|
|
// bound leaves the opening map only; it has no pair to tombstone, and the
|
|
// monotonic host route id never returns.
|
|
openingDuplexRoutes.delete(route.hostRouteId);
|
|
tombstoneBoundDuplexPair(route);
|
|
releaseDuplexRouteSlot(route);
|
|
// A terminalized route reports a null exit code, which the caller treats as a
|
|
// failure.
|
|
settleRouteWait(route, { exitCode: null });
|
|
const confirmed = await closeDuplexChannelTerminal(route.hostRouteId, route.workerSessionId);
|
|
if (!confirmed) {
|
|
// The worker did not acknowledge the close, so the host cannot prove the
|
|
// channel is gone. Fail closed: retire the worker before any reuse.
|
|
log.error(
|
|
{ pluginId },
|
|
"duplex channel close not acknowledged; retiring worker",
|
|
);
|
|
void killProcess();
|
|
}
|
|
}
|
|
|
|
// Count one protocol error for the route. End the route when the count passes
|
|
// the per-route budget, so a flood of malformed or mismatched frames bounds the
|
|
// route.
|
|
function recordDuplexChannelProtocolError(route: DuplexChannelRoute): void {
|
|
route.protocolErrors += 1;
|
|
if (route.protocolErrors > maxDuplexChannelProtocolErrors) {
|
|
void terminalizeDuplexChannelRoute(route);
|
|
}
|
|
}
|
|
|
|
// Deliver one duplex channel chunk to the bound listener in isolation. A
|
|
// listener that throws must not escape the worker stdout notification handler
|
|
// or the buffered replay, so a throw here breaks neither the notification
|
|
// dispatch loop nor the pre-bind drain. The manager catches the error and logs
|
|
// it without the raw bytes. This mirrors the `execute.log` delivery isolation.
|
|
function deliverDuplexChannelChunk(
|
|
listener: (chunk: string) => void,
|
|
chunk: string,
|
|
): void {
|
|
try {
|
|
listener(chunk);
|
|
} catch (err) {
|
|
log.error(
|
|
{ err: err instanceof Error ? err.message : String(err) },
|
|
"duplex channel data delivery threw",
|
|
);
|
|
}
|
|
}
|
|
|
|
// Route one duplex channel data notification to the per-session listener.
|
|
// Deliver only while the route is `open` and the notification carries the exact
|
|
// bound worker session identifier and a valid chunk. Count a mismatched or
|
|
// malformed frame as a protocol error. End the route at once when one chunk is
|
|
// larger than the per-chunk limit or when the cumulative bytes pass the total
|
|
// cap. Buffer a valid frame under the pre-bind bounds when no listener has
|
|
// attached yet. Never log the raw bytes.
|
|
// Resolve one inbound duplex frame to its owning route by the exact pair. Return
|
|
// the live route on an exact live-pair match. Return `"tombstoned"` for a late
|
|
// frame whose exact pair the host already closed; that frame reaches no listener
|
|
// and changes no state. Return `"violation"` for any unknown, foreign,
|
|
// duplicate, ambiguous, or malformed pair; the caller fails closed and retires
|
|
// the worker. The lookup never routes by the worker session id alone.
|
|
function resolveDuplexRouteByPair(
|
|
hostRouteId: string | null,
|
|
workerSessionId: string | null,
|
|
): DuplexChannelRoute | "tombstoned" | "opening" | "violation" {
|
|
if (!hostRouteId || !workerSessionId) return "violation";
|
|
const pairKey = duplexPairKey(hostRouteId, workerSessionId);
|
|
const live = liveDuplexRoutes.get(pairKey);
|
|
if (live && live.state === "open") return live;
|
|
if (duplexPairTombstones.has(pairKey)) return "tombstoned";
|
|
// A frame whose host route id names a route that is still opening arrived
|
|
// before the bind. It is the host's own reserved route, not a foreign frame.
|
|
// The caller defers it, so the bind replays it through the exact-pair routing.
|
|
if (openingDuplexRoutes.has(hostRouteId)) return "opening";
|
|
return "violation";
|
|
}
|
|
|
|
// Route one duplex channel data frame. On a live route with a listener, deliver
|
|
// the chunk transiently. On a live route with no listener, buffer the chunk and
|
|
// charge its raw bytes against the aggregate ledger. The `carried` argument marks
|
|
// a bind-replay frame: it carries the pre-bind token the buffered record must
|
|
// reuse, so the replay transfers the token to the buffered representation with no
|
|
// decrement and no re-reserve. A live frame passes no `carried`, so it reserves a
|
|
// fresh buffered token. A `carried.token` of `null` means no ledger is present.
|
|
function routeDuplexChannelData(
|
|
notification: JsonRpcNotification,
|
|
carried?: { token: ReservationToken | null },
|
|
): void {
|
|
const params = isRecord(notification.params) ? notification.params : {};
|
|
const hostRouteId = readNonEmptyString(params.hostRouteId);
|
|
const workerSessionId = readNonEmptyString(params.workerSessionId);
|
|
const resolved = resolveDuplexRouteByPair(hostRouteId, workerSessionId);
|
|
if (resolved === "tombstoned") {
|
|
// A late frame for a closed pair. It reaches no listener and changes no
|
|
// state. A bind replay never lands here, because the replayed route is live;
|
|
// a carried token stays on its route and the terminal cleanup releases it.
|
|
// Never log the raw frame content.
|
|
return;
|
|
}
|
|
if (resolved === "violation") {
|
|
// An unknown, foreign, duplicate, ambiguous, or malformed pair. Fail closed:
|
|
// retire the worker. Never log the raw frame content.
|
|
retireDuplexWorkerOnViolation("data frame pair");
|
|
return;
|
|
}
|
|
if (resolved === "opening") {
|
|
// The frame arrived before the bind. A replay never lands here, because the
|
|
// route is live during replay. Hold the live frame; the bind replays it.
|
|
bufferPreBindDuplexFrame(hostRouteId, notification);
|
|
return;
|
|
}
|
|
const route = resolved;
|
|
const chunk = params.chunk;
|
|
if (typeof chunk !== "string" || chunk.length === 0) {
|
|
// The exact pair matches, but the chunk is malformed. Count one per-route
|
|
// protocol error. Release a carried replay token first.
|
|
releaseRouteToken(route, carried?.token ?? null);
|
|
recordDuplexChannelProtocolError(route);
|
|
return;
|
|
}
|
|
if (chunk.length > maxDuplexChannelChunkChars) {
|
|
// One inbound chunk is larger than the per-chunk limit. End the route at
|
|
// once. Do not count the chunk as a protocol error.
|
|
releaseRouteToken(route, carried?.token ?? null);
|
|
void terminalizeDuplexChannelRoute(route);
|
|
return;
|
|
}
|
|
// Count the bytes of the chunk. Enforce the cumulative total-byte cap before
|
|
// and after a listener attaches. End the route when the cap is exceeded, so a
|
|
// bound listener cannot receive data past the cap.
|
|
const chunkBytes = Buffer.byteLength(chunk);
|
|
if (route.totalDataBytes + chunkBytes > maxDuplexChannelTotalDataBytes) {
|
|
releaseRouteToken(route, carried?.token ?? null);
|
|
void terminalizeDuplexChannelRoute(route);
|
|
return;
|
|
}
|
|
route.totalDataBytes += chunkBytes;
|
|
if (route.listener) {
|
|
// A listener is attached. Deliver the chunk transiently and release a carried
|
|
// replay token after the synchronous delivery boundary.
|
|
deliverDuplexChannelChunk(route.listener, chunk);
|
|
releaseRouteToken(route, carried?.token ?? null);
|
|
return;
|
|
}
|
|
// No listener attached yet. Buffer the frame under the pre-bind bounds. End
|
|
// the route when the cumulative bytes or the frame count passes the bound.
|
|
if (
|
|
route.buffered.length + 1 > maxDuplexChannelPreBindFrames ||
|
|
route.bufferedChars + chunk.length > maxDuplexChannelPreBindChars
|
|
) {
|
|
releaseRouteToken(route, carried?.token ?? null);
|
|
void terminalizeDuplexChannelRoute(route);
|
|
return;
|
|
}
|
|
let token: ReservationToken | null;
|
|
if (carried) {
|
|
// The bind replay transfers the pre-bind token to the buffered
|
|
// representation. No decrement and no re-reserve, so no admission gap opens.
|
|
token = carried.token;
|
|
transferRouteToken(token, "buffered_chunk");
|
|
} else {
|
|
// A live frame reserves a fresh buffered token for its exact raw bytes.
|
|
const reserved = reserveRouteBytes(route, "buffered_chunk", chunkBytes);
|
|
if (reserved === null) {
|
|
// The aggregate ceiling rejected the reservation. Retain nothing and fail
|
|
// closed with the fixed marker.
|
|
void terminalizeDuplexChannelRoute(route);
|
|
return;
|
|
}
|
|
token = reserved === "no-ledger" ? null : reserved;
|
|
}
|
|
route.buffered.push({ chunk, token });
|
|
route.bufferedChars += chunk.length;
|
|
}
|
|
|
|
// Route one duplex channel exit notification to the wait. Resolve only while
|
|
// the route is `open` and the notification carries the exact bound worker
|
|
// session identifier.
|
|
function routeDuplexChannelExit(notification: JsonRpcNotification): void {
|
|
const params = isRecord(notification.params) ? notification.params : {};
|
|
const hostRouteId = readNonEmptyString(params.hostRouteId);
|
|
const workerSessionId = readNonEmptyString(params.workerSessionId);
|
|
const resolved = resolveDuplexRouteByPair(hostRouteId, workerSessionId);
|
|
if (resolved === "tombstoned") {
|
|
// A late exit for a closed pair. It reaches no wait and changes no state.
|
|
return;
|
|
}
|
|
if (resolved === "violation") {
|
|
// An unknown, foreign, duplicate, ambiguous, or malformed pair. Fail closed:
|
|
// retire the worker.
|
|
retireDuplexWorkerOnViolation("exit frame pair");
|
|
return;
|
|
}
|
|
if (resolved === "opening") {
|
|
// The exit arrived before the bind. Hold it; the bind replays it through the
|
|
// exact-pair routing.
|
|
bufferPreBindDuplexFrame(hostRouteId, notification);
|
|
return;
|
|
}
|
|
const exitCode = typeof params.exitCode === "number" ? params.exitCode : null;
|
|
// A reason-less transport close carries `transportClosed`; a real process exit
|
|
// does not. Carry the discriminator to the wait only when it is a transport
|
|
// close, so a real exit keeps the plain `{ exitCode }` result and the broker
|
|
// still keeps the two apart in the loss taxonomy.
|
|
if (params.transportClosed === true) {
|
|
settleRouteWait(resolved, { exitCode, transportClosed: true });
|
|
return;
|
|
}
|
|
settleRouteWait(resolved, { exitCode });
|
|
}
|
|
|
|
// Hold one frame that arrived before its route bound, in order. The bind replays
|
|
// the held frames through the exact-pair routing. An exit frame never consumes a
|
|
// data hold slot; the host holds it in the single `preBindExit` slot instead, so
|
|
// a worker that batches an exit among enough data frames to fill the hold cannot
|
|
// crowd out a data frame. The data hold ceiling stays one frame above the
|
|
// buffered bound, so the replay's buffered-bound check, not the hold, ends the
|
|
// route. The host counts one protocol error for each data frame past the ceiling,
|
|
// so an early flood bounds the hold instead of growing without limit.
|
|
function bufferPreBindDuplexFrame(
|
|
hostRouteId: string | null,
|
|
notification: JsonRpcNotification,
|
|
): void {
|
|
if (!hostRouteId) return;
|
|
const route = openingDuplexRoutes.get(hostRouteId);
|
|
if (!route) return;
|
|
const params = isRecord(notification.params) ? notification.params : {};
|
|
const workerSessionId = readNonEmptyString(params.workerSessionId);
|
|
if (!workerSessionId) {
|
|
// A malformed pair. Count one per-route protocol error and hold nothing.
|
|
recordDuplexChannelProtocolError(route);
|
|
return;
|
|
}
|
|
if (notification.method === DUPLEX_CHANNEL_EXIT_NOTIFICATION) {
|
|
// Normalize the exit to the narrow duplex-event schema. An exit retains only
|
|
// a bounded scalar, so reserve a zero-byte token that carries the record
|
|
// through the one cleanup path. Release any earlier held exit token first, so
|
|
// a replaced exit never leaks its reservation.
|
|
const exitCode = typeof params.exitCode === "number" ? params.exitCode : null;
|
|
if (route.preBindExit) releaseRouteToken(route, route.preBindExit.token);
|
|
const reserved = reserveRouteBytes(route, "pre_bind_event", 0);
|
|
if (reserved === null) {
|
|
// The aggregate ceiling rejected the reservation. Fail closed.
|
|
void terminalizeDuplexChannelRoute(route);
|
|
return;
|
|
}
|
|
route.preBindExit = {
|
|
workerSessionId,
|
|
exitCode,
|
|
token: reserved === "no-ledger" ? null : reserved,
|
|
};
|
|
return;
|
|
}
|
|
// A data event. Validate and normalize it to the narrow duplex-event schema
|
|
// before any retention.
|
|
const chunk = params.chunk;
|
|
if (typeof chunk !== "string" || chunk.length === 0) {
|
|
recordDuplexChannelProtocolError(route);
|
|
return;
|
|
}
|
|
if (route.preBind.length >= maxDuplexChannelPreBindHoldFrames) {
|
|
recordDuplexChannelProtocolError(route);
|
|
return;
|
|
}
|
|
// Reserve the exact retained raw byte count before the host holds the event.
|
|
const reserved = reserveRouteBytes(route, "pre_bind_event", Buffer.byteLength(chunk));
|
|
if (reserved === null) {
|
|
// The aggregate ceiling rejected the reservation. The caller retains nothing
|
|
// and the route fails closed with the fixed marker.
|
|
log.warn({ pluginId, reason: DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED }, "duplex pre-bind hold rejected");
|
|
void terminalizeDuplexChannelRoute(route);
|
|
return;
|
|
}
|
|
route.preBind.push({
|
|
workerSessionId,
|
|
chunk,
|
|
token: reserved === "no-ledger" ? null : reserved,
|
|
});
|
|
}
|
|
|
|
// Replay the frames a route held before it bound. The route is live now, so the
|
|
// exact-pair routing delivers each held frame or fails closed on a mismatch.
|
|
// Replay the held data frames first, in order, then the held exit last, so the
|
|
// data delivers before the exit resolves the wait, which matches a real worker's
|
|
// order. A frame that ends the route terminalizes it, and every later frame in
|
|
// the replay is a no-op, because the routing functions drop a frame for a route
|
|
// that is not `open`.
|
|
function replayPreBindDuplexFrames(route: DuplexChannelRoute): void {
|
|
const held = route.preBind;
|
|
route.preBind = [];
|
|
for (const event of held) {
|
|
if (route.terminalized) {
|
|
// The route ended mid-replay. Release the remaining held tokens, so the
|
|
// unreplayed events retain nothing.
|
|
releaseRouteToken(route, event.token);
|
|
continue;
|
|
}
|
|
// Reconstruct a transient data notification for the exact-pair routing. The
|
|
// host holds only the bounded event, so it builds this notification for the
|
|
// routing step and discards it at once. The carried token moves to the
|
|
// buffered representation without a decrement or a re-reserve.
|
|
routeDuplexChannelData(
|
|
{
|
|
jsonrpc: "2.0",
|
|
method: DUPLEX_CHANNEL_DATA_NOTIFICATION,
|
|
params: {
|
|
hostRouteId: route.hostRouteId,
|
|
workerSessionId: event.workerSessionId,
|
|
chunk: event.chunk,
|
|
},
|
|
},
|
|
{ token: event.token },
|
|
);
|
|
}
|
|
const heldExit = route.preBindExit;
|
|
route.preBindExit = null;
|
|
if (heldExit) {
|
|
// The exit retains only a bounded scalar. Release its placeholder token, then
|
|
// resolve the wait through the exact-pair routing when the route still lives.
|
|
releaseRouteToken(route, heldExit.token);
|
|
if (!route.terminalized) {
|
|
routeDuplexChannelExit({
|
|
jsonrpc: "2.0",
|
|
method: DUPLEX_CHANNEL_EXIT_NOTIFICATION,
|
|
params: {
|
|
hostRouteId: route.hostRouteId,
|
|
workerSessionId: heldExit.workerSessionId,
|
|
exitCode: heldExit.exitCode,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close every route on a worker exit. The worker is gone, so the manager
|
|
// resolves each wait with the fixed non-secret exit, releases each aggregate
|
|
// slot, and clears the live, opening, and tombstone state. A worker exit is a
|
|
// worker retirement, so the host drops every tombstone; the monotonic host route
|
|
// id never returns, so no closed pair can revive on a restart. The pending
|
|
// channel calls reject through `rejectAllPending`.
|
|
function closeDuplexChannelRouteOnWorkerExit(): void {
|
|
// Enumerate the opening index, the live index, and the terminal registry. A
|
|
// terminalized route left the opening and live maps but may still hold buffered
|
|
// bytes for a late listener; the worker is gone, so the host releases them now.
|
|
const routes = [
|
|
...openingDuplexRoutes.values(),
|
|
...liveDuplexRoutes.values(),
|
|
...terminalDuplexRoutes,
|
|
];
|
|
openingDuplexRoutes.clear();
|
|
liveDuplexRoutes.clear();
|
|
duplexPairTombstones.clear();
|
|
for (const route of routes) {
|
|
if (!route.terminalized) {
|
|
route.terminalized = true;
|
|
route.state = "closed";
|
|
route.listener = null;
|
|
route.bufferedChars = 0;
|
|
clearDuplexChannelLifetimeTimer(route);
|
|
releaseDuplexRouteSlot(route);
|
|
settleRouteWait(route, { exitCode: null });
|
|
}
|
|
// Release every token the route still holds and clear its retained
|
|
// representations, exactly once. A route already drained by a late listener
|
|
// holds no token, so this is harmless and leaves the ledger at zero.
|
|
discardRouteRetained(route);
|
|
}
|
|
// Clear the terminal registry. Every route in it was just discarded above.
|
|
terminalDuplexRoutes.clear();
|
|
}
|
|
|
|
// Open one live generic duplex channel route. Reserve the route before the open
|
|
// call, bind the worker session identifier one time on the first successful
|
|
// open reply, and return a session a caller drives. Terminalize the route on
|
|
// every open failure path.
|
|
async function openDuplexChannel(
|
|
input: DuplexChannelOpenInput,
|
|
): Promise<DuplexChannelHostSession> {
|
|
const hostRouteId = nextDuplexHostRouteId();
|
|
let settleWait: (value: { exitCode: number | null; transportClosed?: boolean }) => void =
|
|
() => {};
|
|
const waitPromise = new Promise<{ exitCode: number | null; transportClosed?: boolean }>(
|
|
(resolve) => {
|
|
settleWait = resolve;
|
|
},
|
|
);
|
|
const route: DuplexChannelRoute = {
|
|
hostRouteId,
|
|
state: "reserved",
|
|
workerSessionId: null,
|
|
listener: null,
|
|
buffered: [],
|
|
bufferedChars: 0,
|
|
pendingRequests: 0,
|
|
protocolErrors: 0,
|
|
totalDataBytes: 0,
|
|
lifetimeTimer: null,
|
|
terminalized: false,
|
|
settleWait,
|
|
preBind: [],
|
|
preBindExit: null,
|
|
retainedTokens: new Set<ReservationToken>(),
|
|
};
|
|
// Reserve one aggregate route slot before any work. When the process-scoped
|
|
// ceiling is full, reject with the fixed route-busy error and open nothing, so
|
|
// an active channel never downgrades and the ceiling never overcommits.
|
|
if (!acquireDuplexRouteSlot(route)) {
|
|
throw new Error(DUPLEX_CHANNEL_ROUTE_BUSY);
|
|
}
|
|
openingDuplexRoutes.set(hostRouteId, route);
|
|
|
|
route.state = "opening";
|
|
let openResult: HostToWorkerMethods["duplexChannelOpen"][1];
|
|
try {
|
|
openResult = await callInternal(
|
|
"duplexChannelOpen",
|
|
{
|
|
hostRouteId,
|
|
driverKey: input.driverKey,
|
|
companyId: input.companyId,
|
|
environmentId: input.environmentId,
|
|
providerLeaseId: input.providerLeaseId,
|
|
command: input.command,
|
|
},
|
|
duplexChannelOpenTimeoutMs,
|
|
);
|
|
} catch (err) {
|
|
// A send failure, an RPC rejection, or an open timeout. Terminalize the
|
|
// route exactly once and fail closed.
|
|
await terminalizeDuplexChannelRoute(route);
|
|
throw err instanceof Error ? err : new Error(DUPLEX_CHANNEL_OPEN_FAILED);
|
|
}
|
|
|
|
const workerSessionId = readBindableWorkerSessionId(route, openResult);
|
|
// Verify the worker echoed the exact host route id the open request carried.
|
|
// A reply with a missing or a mismatched host route id never binds, so the
|
|
// host binds only a reply that proves the worker holds the exact pair.
|
|
const echoedHostRouteId = isRecord(openResult)
|
|
? readNonEmptyString(openResult.hostRouteId)
|
|
: null;
|
|
if (!workerSessionId || echoedHostRouteId !== hostRouteId) {
|
|
// A malformed reply, a mismatched host route id, or a route that already
|
|
// left `opening`. A late or a duplicate reply never binds, revives, or
|
|
// reopens a route.
|
|
await terminalizeDuplexChannelRoute(route);
|
|
throw new Error(DUPLEX_CHANNEL_OPEN_FAILED);
|
|
}
|
|
const pairKey = duplexPairKey(hostRouteId, workerSessionId);
|
|
if (liveDuplexRoutes.has(pairKey) || duplexPairTombstones.has(pairKey)) {
|
|
// The worker returned a pair that is already live or already tombstoned. Fail
|
|
// closed: terminalize this route and retire the worker before any reuse.
|
|
await terminalizeDuplexChannelRoute(route);
|
|
retireDuplexWorkerOnViolation("duplicate bound pair");
|
|
throw new Error(DUPLEX_CHANNEL_OPEN_FAILED);
|
|
}
|
|
// Bind the worker session identifier one time and move the route from the
|
|
// opening map to the live map under the exact pair key.
|
|
route.workerSessionId = workerSessionId;
|
|
route.state = "open";
|
|
openingDuplexRoutes.delete(hostRouteId);
|
|
liveDuplexRoutes.set(pairKey, route);
|
|
|
|
// Start the route lifetime timer now the route is open. The route ends when
|
|
// the timer expires. Every terminal path and the worker-exit path clears the
|
|
// timer. Unreference the timer so it never blocks the host process shutdown.
|
|
route.lifetimeTimer = setTimeout(() => {
|
|
void terminalizeDuplexChannelRoute(route);
|
|
}, maxDuplexChannelDurationMs);
|
|
route.lifetimeTimer.unref?.();
|
|
|
|
// Replay any frame that arrived before the bind. The route is live now, so the
|
|
// exact-pair routing delivers each held frame or fails closed on a mismatch.
|
|
replayPreBindDuplexFrames(route);
|
|
|
|
// Send one host→worker request under the pending-request bound. End the route
|
|
// when too many requests are in-flight, so a worker that never replies cannot
|
|
// make the host hold an unbounded number of pending requests.
|
|
const sendBoundedRequest = <
|
|
M extends "duplexChannelWrite" | "duplexChannelStop",
|
|
>(
|
|
method: M,
|
|
params: HostToWorkerMethods[M][0],
|
|
): void => {
|
|
if (route.state !== "open") return;
|
|
if (route.pendingRequests >= maxDuplexChannelPendingRequests) {
|
|
void terminalizeDuplexChannelRoute(route);
|
|
return;
|
|
}
|
|
// Reserve the exact UTF-8 byte count of a host→worker write against the
|
|
// aggregate ledger before `callInternal` retains the payload. A pending write
|
|
// RPC holds `params.data` until it settles, so this reservation bounds the
|
|
// aggregate host→worker pending-write bytes across every route. Compute the
|
|
// byte count with `Buffer.byteLength`, not `data.length`, because one
|
|
// character can encode as several UTF-8 bytes. A stop request carries no
|
|
// payload, so it reserves nothing. When no ledger is present, admit the write
|
|
// with no token (a unit test constructs the handle this way).
|
|
let pendingWriteToken: ReservationToken | null = null;
|
|
if (method === "duplexChannelWrite" && duplexAggregateByteLedger) {
|
|
const data = (params as HostToWorkerMethods["duplexChannelWrite"][0]).data;
|
|
const bytes = Buffer.byteLength(data, "utf8");
|
|
pendingWriteToken = duplexAggregateByteLedger.reserve("pending_write", bytes);
|
|
if (!pendingWriteToken) {
|
|
// The reservation would pass the aggregate ceiling. Retain nothing, do
|
|
// not enqueue the RPC, and end the route fail-closed with the aggregate
|
|
// marker, not the route-busy marker.
|
|
log.warn(
|
|
{ pluginId, reason: DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED },
|
|
"duplex pending write reservation rejected",
|
|
);
|
|
void terminalizeDuplexChannelRoute(route);
|
|
return;
|
|
}
|
|
}
|
|
route.pendingRequests += 1;
|
|
// Meter the child-stdin transport buffer only for a duplex write. A stop
|
|
// request carries a tiny fixed frame that the host never lets grow, so it
|
|
// does not meter or reject. The write path reserves the serialized frame in
|
|
// `sendMessage` right before the stdin write.
|
|
const meterDuplexWrite = method === "duplexChannelWrite";
|
|
void callInternal(method, params, duplexChannelOpenTimeoutMs, undefined, meterDuplexWrite)
|
|
.catch((err: unknown) => {
|
|
if (err instanceof DuplexAggregateBytesExceededError) {
|
|
// The transport reservation failed. The host did not write the frame.
|
|
// End the route fail-closed with the aggregate marker, not the
|
|
// route-busy marker.
|
|
log.warn(
|
|
{ pluginId, reason: DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED },
|
|
"duplex stdin write reservation rejected",
|
|
);
|
|
void terminalizeDuplexChannelRoute(route);
|
|
}
|
|
})
|
|
.finally(() => {
|
|
route.pendingRequests -= 1;
|
|
// Release the pending-write token one time, after the RPC settles on any
|
|
// path: success, error, timeout, worker exit, or shutdown. The token is
|
|
// not in `route.retainedTokens`, so route terminalization never releases
|
|
// it; only this settlement releases it. The separate `stdin_write` token
|
|
// covers the serialized frame and releases on the stream flush, the
|
|
// stream error, the stream close, or the worker exit, never here.
|
|
if (pendingWriteToken) {
|
|
duplexAggregateByteLedger?.release(pendingWriteToken);
|
|
}
|
|
});
|
|
};
|
|
|
|
return {
|
|
onData(listener: (chunk: string) => void): void {
|
|
route.listener = listener;
|
|
if (route.buffered.length > 0) {
|
|
const pending = route.buffered;
|
|
route.buffered = [];
|
|
route.bufferedChars = 0;
|
|
for (const record of pending) {
|
|
// Deliver each buffered record, drop its retained buffer, and release
|
|
// its exact token before the drain proceeds to the next record. A
|
|
// terminal route drains through this same code.
|
|
deliverDuplexChannelChunk(listener, record.chunk);
|
|
releaseRouteToken(route, record.token);
|
|
}
|
|
// The buffered records are gone, so the route no longer holds terminal
|
|
// bytes for a late listener.
|
|
terminalDuplexRoutes.delete(route);
|
|
}
|
|
},
|
|
write(data: string): void {
|
|
const sid = route.workerSessionId;
|
|
if (route.state !== "open" || !sid) return;
|
|
if (data.length > maxDuplexChannelWriteChars) {
|
|
// The write is larger than the size bound. End the route before the
|
|
// write reaches the worker.
|
|
void terminalizeDuplexChannelRoute(route);
|
|
return;
|
|
}
|
|
sendBoundedRequest("duplexChannelWrite", {
|
|
hostRouteId: route.hostRouteId,
|
|
workerSessionId: sid,
|
|
data,
|
|
});
|
|
},
|
|
wait(): Promise<{ exitCode: number | null }> {
|
|
return waitPromise;
|
|
},
|
|
kill(): void {
|
|
const sid = route.workerSessionId;
|
|
if (!sid) return;
|
|
sendBoundedRequest("duplexChannelStop", { hostRouteId: route.hostRouteId, workerSessionId: sid });
|
|
},
|
|
async close(): Promise<void> {
|
|
await terminalizeDuplexChannelRoute(route);
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extract the single company a worker→host call references, mirroring the SDK
|
|
* governed-access gate's own derivation (host-client-factory.ts
|
|
* `requestedCompanyScope`) so a proactive call resolves to exactly the company
|
|
* the gate would require:
|
|
* - explicit `params.companyId`;
|
|
* - a company-scoped state key (`scopeKind: "company"` + `scopeId`);
|
|
* - `events.subscribe`'s `params.filter.companyId` (how the SDK's
|
|
* `ctx.events.on(name, { companyId }, fn)` issues its subscribe).
|
|
*
|
|
* Returns null whenever the gate treats the call as a wildcard (`companies.list`,
|
|
* a `scopeKind: "company"` key with no `scopeId`) or as referencing no company
|
|
* (instance-scoped state, an unfiltered subscribe). A wildcard is deliberately
|
|
* NOT granted proactively: proactive resolution only ever admits a single,
|
|
* explicit company, never "all". This keeps the resolver and the gate in
|
|
* lockstep in the functional direction (LOOA-693 AC#4 / LOOA-695).
|
|
*/
|
|
function referencedCompanyId(method: string, params: unknown): string | null {
|
|
// Gate returns { kind: "all" } for companies.list regardless of params —
|
|
// never a single company — so proactive access declines it here.
|
|
if (method === "companies.list") return null;
|
|
if (!isRecord(params)) return null;
|
|
const direct = readNonEmptyString(params.companyId);
|
|
if (direct) return direct;
|
|
if (params.scopeKind === "company") {
|
|
// scopeId present → that company; absent → wildcard ("all") in the gate,
|
|
// which we never grant proactively → null.
|
|
return readNonEmptyString(params.scopeId);
|
|
}
|
|
if (method === "events.subscribe" && isRecord(params.filter)) {
|
|
return readNonEmptyString(params.filter.companyId);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function contextForWorkerMessage(message: JsonRpcRequest | JsonRpcNotification): WorkerHostCallContext {
|
|
const invocationId = readNonEmptyString(
|
|
(message as { paperclipInvocationId?: unknown }).paperclipInvocationId,
|
|
);
|
|
if (!invocationId) {
|
|
// No host-issued invocation is being echoed. This is a genuinely
|
|
// proactive worker→host call (timer/loop). If it references a company the
|
|
// plugin is authorized to act on proactively, resolve it to that
|
|
// company's scope so the governed-access gate admits it. This never
|
|
// widens access beyond the plugin's configured companies, and only
|
|
// applies when the worker is NOT inside a host-issued invocation (which
|
|
// would carry an id and keep its strict single-company match below).
|
|
const proactiveCompanyId = referencedCompanyId(
|
|
message.method,
|
|
(message as { params?: unknown }).params,
|
|
);
|
|
if (proactiveCompanyId && proactiveCompanyScopes.has(proactiveCompanyId)) {
|
|
return { invocationScope: { companyId: proactiveCompanyId } };
|
|
}
|
|
const hasActiveInvocation = activeInvocations.size > 0 ||
|
|
Array.from(pendingRequests.values()).some((pending) => pending.invocationId);
|
|
return hasActiveInvocation ? { invalidInvocationScope: true } : {};
|
|
}
|
|
const entry = activeInvocations.get(invocationId);
|
|
if (!entry) return { invalidInvocationScope: true };
|
|
return { invocationScope: entry.scope, traceparent: entry.traceparent };
|
|
}
|
|
|
|
/**
|
|
* Handle a JSON-RPC request from the worker (worker→host call).
|
|
*/
|
|
async function handleWorkerRequest(request: JsonRpcRequest): Promise<void> {
|
|
const method = request.method as WorkerToHostMethodName;
|
|
const handler = options.hostHandlers[method] as
|
|
| ((params: unknown, context?: WorkerHostCallContext) => Promise<unknown>)
|
|
| undefined;
|
|
|
|
if (!handler) {
|
|
log.warn({ method }, "worker called unregistered host method");
|
|
try {
|
|
sendMessage(
|
|
createErrorResponse(
|
|
request.id,
|
|
JSONRPC_ERROR_CODES.METHOD_NOT_FOUND,
|
|
`Host does not handle method "${method}"`,
|
|
),
|
|
);
|
|
} catch {
|
|
// Worker may have exited, ignore send error
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await handler(request.params, contextForWorkerMessage(request));
|
|
sendMessage({
|
|
jsonrpc: JSONRPC_VERSION,
|
|
id: request.id,
|
|
result: result ?? null,
|
|
});
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
log.error({ method, err: errorMessage }, "host handler error");
|
|
try {
|
|
sendMessage(
|
|
createErrorResponse(
|
|
request.id,
|
|
errorCodeForWorkerHostError(err),
|
|
errorMessage,
|
|
),
|
|
);
|
|
} catch {
|
|
// Worker may have exited, ignore send error
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle a JSON-RPC notification from the worker (fire-and-forget).
|
|
*
|
|
* The `log` notification is the primary case — worker `ctx.logger` calls
|
|
* arrive here. We append structured plugin context (pluginId, timestamp,
|
|
* level) so that every log entry is queryable per the spec (§26.1).
|
|
*/
|
|
function handleWorkerNotification(notification: JsonRpcNotification): void {
|
|
if (notification.method === "log") {
|
|
const params = notification.params as {
|
|
level?: string;
|
|
message?: string;
|
|
meta?: Record<string, unknown>;
|
|
} | null;
|
|
const level = params?.level ?? "info";
|
|
const msg = params?.message ?? "";
|
|
const meta = params?.meta;
|
|
|
|
// Build a structured log object that includes the plugin context fields
|
|
// required by §26.1: pluginId, timestamp, level, message, and metadata.
|
|
// The child logger already carries `pluginId` in its bindings, but we
|
|
// add explicit `pluginLogLevel` and `pluginTimestamp` so downstream
|
|
// consumers (log storage, UI queries) can filter without parsing.
|
|
const logFields: Record<string, unknown> = {
|
|
...meta,
|
|
pluginLogLevel: level,
|
|
pluginTimestamp: new Date().toISOString(),
|
|
};
|
|
|
|
if (level === "error") {
|
|
log.error(logFields, `[plugin] ${msg}`);
|
|
} else if (level === "warn") {
|
|
log.warn(logFields, `[plugin] ${msg}`);
|
|
} else if (level === "debug") {
|
|
log.debug(logFields, `[plugin] ${msg}`);
|
|
} else {
|
|
log.info(logFields, `[plugin] ${msg}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Execute-log notifications: deliver one incremental output chunk to the
|
|
// host-owned execute route for the active execute call.
|
|
if (notification.method === "execute.log") {
|
|
routeExecuteLogNotification(notification);
|
|
return;
|
|
}
|
|
|
|
// Setup-token login pseudo-terminal notifications: deliver output
|
|
// and the exit to the one host-owned login route, bound by the worker session
|
|
// identifier while the route is open.
|
|
if (notification.method === SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION) {
|
|
routeSetupTokenPtyOutput(notification);
|
|
return;
|
|
}
|
|
if (notification.method === SETUP_TOKEN_PTY_EXIT_NOTIFICATION) {
|
|
routeSetupTokenPtyExit(notification);
|
|
return;
|
|
}
|
|
|
|
// Duplex channel notifications: deliver data and the exit to the one
|
|
// host-owned duplex route, bound by the worker session identifier while the
|
|
// route is open.
|
|
if (notification.method === DUPLEX_CHANNEL_DATA_NOTIFICATION) {
|
|
routeDuplexChannelData(notification);
|
|
return;
|
|
}
|
|
if (notification.method === DUPLEX_CHANNEL_EXIT_NOTIFICATION) {
|
|
routeDuplexChannelExit(notification);
|
|
return;
|
|
}
|
|
|
|
// Stream notifications: forward to the stream bus via callback
|
|
if (
|
|
notification.method === "streams.open" ||
|
|
notification.method === "streams.emit" ||
|
|
notification.method === "streams.close"
|
|
) {
|
|
const params = (notification.params ?? {}) as Record<string, unknown>;
|
|
const companyId = String(params.companyId ?? "");
|
|
const context = contextForWorkerMessage(notification);
|
|
if (context.invalidInvocationScope) {
|
|
log.warn(
|
|
{ method: notification.method, companyId },
|
|
"dropping plugin stream notification with invalid invocation scope",
|
|
);
|
|
return;
|
|
}
|
|
const allowedCompanyId = context.invocationScope?.companyId;
|
|
if (allowedCompanyId && companyId !== allowedCompanyId) {
|
|
log.warn(
|
|
{ method: notification.method, companyId, allowedCompanyId },
|
|
"dropping plugin stream notification outside invocation company scope",
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Track open channels so we can emit synthetic close on crash
|
|
if (notification.method === "streams.open") {
|
|
const ch = String(params.channel ?? "");
|
|
if (ch) openStreamChannels.set(ch, companyId);
|
|
} else if (notification.method === "streams.close") {
|
|
openStreamChannels.delete(String(params.channel ?? ""));
|
|
}
|
|
|
|
if (options.onStreamNotification) {
|
|
try {
|
|
options.onStreamNotification(notification.method, params);
|
|
} catch (err) {
|
|
log.error(
|
|
{
|
|
method: notification.method,
|
|
err: err instanceof Error ? err.message : String(err),
|
|
},
|
|
"stream notification handler failed",
|
|
);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
log.debug({ method: notification.method }, "received notification from worker");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Process lifecycle
|
|
// -----------------------------------------------------------------------
|
|
|
|
function spawnProcess(): ChildProcess {
|
|
// Security: Do NOT spread process.env into the worker. Plugins should only
|
|
// receive a minimal, controlled environment to prevent leaking host
|
|
// secrets (like DATABASE_URL, internal API keys, etc.).
|
|
const workerEnv: Record<string, string> = {
|
|
...options.env,
|
|
PATH: process.env.PATH ?? "",
|
|
NODE_PATH: process.env.NODE_PATH ?? "",
|
|
PAPERCLIP_PLUGIN_ID: pluginId,
|
|
NODE_ENV: process.env.NODE_ENV ?? "production",
|
|
TZ: process.env.TZ ?? "UTC",
|
|
};
|
|
|
|
const child = fork(options.entrypointPath, [], {
|
|
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
|
execArgv: options.execArgv ?? [],
|
|
env: workerEnv,
|
|
// Don't let the child keep the parent alive
|
|
detached: false,
|
|
});
|
|
|
|
return child;
|
|
}
|
|
|
|
function attachStdioHandlers(child: ChildProcess): void {
|
|
// Read NDJSON from stdout
|
|
if (child.stdout) {
|
|
readline = createInterface({ input: child.stdout });
|
|
readline.on("line", handleLine);
|
|
}
|
|
|
|
// Release the outstanding child-stdin transport tokens when the stdin stream
|
|
// errors or closes, because each discards the stdin write buffer. The `error`
|
|
// listener also stops an unhandled EPIPE from a child that closed its stdin.
|
|
if (child.stdin) {
|
|
child.stdin.on("error", () => {
|
|
releaseAllPendingStdinWriteTokens();
|
|
});
|
|
child.stdin.on("close", () => {
|
|
releaseAllPendingStdinWriteTokens();
|
|
});
|
|
}
|
|
|
|
// Capture stderr for logging
|
|
if (child.stderr) {
|
|
stderrReadline = createInterface({ input: child.stderr });
|
|
stderrReadline.on("line", (line: string) => {
|
|
stderrExcerpt = appendStderrExcerpt(stderrExcerpt, line);
|
|
log.warn({ stream: "stderr" }, `[plugin stderr] ${line}`);
|
|
});
|
|
}
|
|
|
|
// Handle process exit
|
|
child.on("exit", (code, signal) => {
|
|
handleProcessExit(code, signal);
|
|
});
|
|
|
|
// Handle process errors (e.g. spawn failure)
|
|
child.on("error", (err) => {
|
|
log.error({ err: err.message }, "worker process error");
|
|
if (emitter.listenerCount("error") > 0) {
|
|
emitter.emit("error", { pluginId, error: err });
|
|
}
|
|
if (status === "starting") {
|
|
setStatus("crashed");
|
|
rejectAllPending(
|
|
new Error(formatWorkerFailureMessage(
|
|
`Worker process failed to start: ${err.message}`,
|
|
stderrExcerpt,
|
|
)),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
function handleProcessExit(
|
|
code: number | null,
|
|
signal: NodeJS.Signals | null,
|
|
): void {
|
|
const wasIntentional = intentionalStop;
|
|
|
|
// Clean up readline interfaces
|
|
if (readline) {
|
|
readline.close();
|
|
readline = null;
|
|
}
|
|
if (stderrReadline) {
|
|
stderrReadline.close();
|
|
stderrReadline = null;
|
|
}
|
|
childProcess = null;
|
|
startedAt = null;
|
|
|
|
// The worker exit discards the child-stdin write buffer, so release every
|
|
// outstanding transport token. The RPC rejections below never release these
|
|
// tokens; only this sweep, a stream flush, a stream error, or a stream close
|
|
// releases them.
|
|
releaseAllPendingStdinWriteTokens();
|
|
|
|
// Reject all pending requests
|
|
rejectAllPending(
|
|
new Error(formatWorkerFailureMessage(
|
|
`Worker process exited (code=${code}, signal=${signal})`,
|
|
stderrExcerpt,
|
|
)),
|
|
);
|
|
|
|
// Close the one login pseudo-terminal route with a fixed non-secret exit and
|
|
// clear the route one time. The pending pseudo-terminal calls
|
|
// already rejected through `rejectAllPending`.
|
|
closeSetupTokenPtyRouteOnWorkerExit();
|
|
|
|
// Close the one duplex channel route the same way. The pending channel calls
|
|
// already rejected through `rejectAllPending`.
|
|
closeDuplexChannelRouteOnWorkerExit();
|
|
|
|
// Emit synthetic close for any orphaned stream channels so SSE clients
|
|
// are notified instead of hanging indefinitely.
|
|
if (openStreamChannels.size > 0 && options.onStreamNotification) {
|
|
for (const [channel, companyId] of openStreamChannels) {
|
|
try {
|
|
options.onStreamNotification("streams.close", { channel, companyId });
|
|
} catch {
|
|
// Best-effort cleanup — don't let it interfere with exit handling
|
|
}
|
|
}
|
|
openStreamChannels.clear();
|
|
}
|
|
|
|
emitter.emit("exit", { pluginId, code, signal });
|
|
|
|
if (wasIntentional) {
|
|
// Graceful stop — status is already "stopping" or will be set to "stopped"
|
|
setStatus("stopped");
|
|
log.info({ code, signal }, "worker process stopped");
|
|
return;
|
|
}
|
|
|
|
// Unexpected exit — crash recovery
|
|
totalCrashes++;
|
|
const now = Date.now();
|
|
|
|
// Reset consecutive crash counter if enough time passed
|
|
if (lastCrashAt !== null && now - lastCrashAt > CRASH_WINDOW_MS) {
|
|
consecutiveCrashes = 0;
|
|
}
|
|
consecutiveCrashes++;
|
|
lastCrashAt = now;
|
|
|
|
log.error(
|
|
{ code, signal, consecutiveCrashes, totalCrashes },
|
|
"worker process crashed",
|
|
);
|
|
|
|
const willRestart =
|
|
autoRestart && consecutiveCrashes <= MAX_CONSECUTIVE_CRASHES;
|
|
|
|
setStatus("crashed");
|
|
emitter.emit("crash", { pluginId, code, signal, willRestart });
|
|
|
|
if (willRestart) {
|
|
scheduleRestart();
|
|
} else {
|
|
log.error(
|
|
{ consecutiveCrashes, maxCrashes: MAX_CONSECUTIVE_CRASHES },
|
|
"max consecutive crashes reached, not restarting",
|
|
);
|
|
}
|
|
}
|
|
|
|
function rejectAllPending(error: Error): void {
|
|
for (const [id, pending] of pendingRequests) {
|
|
clearTimeout(pending.timer);
|
|
pending.resolve(
|
|
createErrorResponse(
|
|
pending.id,
|
|
PLUGIN_RPC_ERROR_CODES.WORKER_UNAVAILABLE,
|
|
error.message,
|
|
) as JsonRpcResponse,
|
|
);
|
|
}
|
|
pendingRequests.clear();
|
|
for (const invocation of activeInvocations.values()) {
|
|
if (invocation.timer) clearTimeout(invocation.timer);
|
|
}
|
|
activeInvocations.clear();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Crash recovery with exponential backoff
|
|
// -----------------------------------------------------------------------
|
|
|
|
function computeBackoffMs(): number {
|
|
// Exponential backoff: MIN_BACKOFF * MULTIPLIER^(consecutiveCrashes - 1)
|
|
const delay =
|
|
MIN_BACKOFF_MS * Math.pow(BACKOFF_MULTIPLIER, consecutiveCrashes - 1);
|
|
// Add jitter: ±25%
|
|
const jitter = delay * 0.25 * (Math.random() * 2 - 1);
|
|
return Math.min(Math.round(delay + jitter), MAX_BACKOFF_MS);
|
|
}
|
|
|
|
function scheduleRestart(): void {
|
|
const delay = computeBackoffMs();
|
|
nextRestartAt = Date.now() + delay;
|
|
|
|
setStatus("backoff");
|
|
|
|
log.info(
|
|
{ delayMs: delay, consecutiveCrashes },
|
|
"scheduling restart with backoff",
|
|
);
|
|
|
|
backoffTimer = setTimeout(async () => {
|
|
backoffTimer = null;
|
|
nextRestartAt = null;
|
|
try {
|
|
await startInternal();
|
|
} catch (err) {
|
|
log.error(
|
|
{ err: err instanceof Error ? err.message : String(err) },
|
|
"restart after backoff failed",
|
|
);
|
|
}
|
|
}, delay);
|
|
}
|
|
|
|
function cancelPendingRestart(): void {
|
|
if (backoffTimer !== null) {
|
|
clearTimeout(backoffTimer);
|
|
backoffTimer = null;
|
|
nextRestartAt = null;
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Start / Stop
|
|
// -----------------------------------------------------------------------
|
|
|
|
async function startInternal(): Promise<void> {
|
|
if (status === "running" || status === "starting") {
|
|
throw new Error(`Worker for plugin "${pluginId}" is already ${status}`);
|
|
}
|
|
|
|
intentionalStop = false;
|
|
setStatus("starting");
|
|
stderrExcerpt = "";
|
|
|
|
const child = spawnProcess();
|
|
childProcess = child;
|
|
attachStdioHandlers(child);
|
|
startedAt = Date.now();
|
|
|
|
// Send the initialize RPC call
|
|
const initParams: InitializeParams = {
|
|
manifest: options.manifest,
|
|
config: options.config,
|
|
instanceInfo: options.instanceInfo,
|
|
apiVersion: options.apiVersion,
|
|
databaseNamespace: options.databaseNamespace ?? null,
|
|
};
|
|
|
|
try {
|
|
const result = await callInternal(
|
|
"initialize",
|
|
initParams,
|
|
INITIALIZE_TIMEOUT_MS,
|
|
) as { ok?: boolean; supportedMethods?: string[] } | undefined;
|
|
if (!result || !result.ok) {
|
|
throw new Error("Worker initialize returned ok=false");
|
|
}
|
|
supportedMethods = result.supportedMethods ?? [];
|
|
} catch (err) {
|
|
// Initialize failed — kill the process and propagate
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
log.error({ err: msg }, "worker initialize failed");
|
|
await killProcess();
|
|
setStatus("crashed");
|
|
throw new Error(`Worker initialize failed for "${pluginId}": ${msg}`);
|
|
}
|
|
|
|
// Reset crash counter on successful start
|
|
consecutiveCrashes = 0;
|
|
setStatus("running");
|
|
emitter.emit("ready", { pluginId });
|
|
log.info({ pid: child.pid }, "worker process started and initialized");
|
|
}
|
|
|
|
async function stopInternal(): Promise<void> {
|
|
cancelPendingRestart();
|
|
|
|
if (status === "stopped" || status === "stopping") {
|
|
return;
|
|
}
|
|
|
|
intentionalStop = true;
|
|
setStatus("stopping");
|
|
|
|
if (!childProcess) {
|
|
setStatus("stopped");
|
|
return;
|
|
}
|
|
|
|
// Step 1: Send shutdown RPC and wait for the worker to exit gracefully.
|
|
// We race the shutdown call against a timeout. The worker should process
|
|
// the shutdown and exit on its own within the drain period.
|
|
try {
|
|
await Promise.race([
|
|
callInternal("shutdown", {} as Record<string, never>, SHUTDOWN_DRAIN_MS),
|
|
waitForExit(SHUTDOWN_DRAIN_MS),
|
|
]);
|
|
} catch {
|
|
// Shutdown call failed or timed out — proceed to kill
|
|
log.warn("shutdown RPC failed or timed out, escalating to SIGTERM");
|
|
}
|
|
|
|
// Give the process a brief moment to exit after the shutdown response
|
|
if (childProcess) {
|
|
await waitForExit(500);
|
|
}
|
|
|
|
// Check if process already exited
|
|
if (!childProcess) {
|
|
setStatus("stopped");
|
|
return;
|
|
}
|
|
|
|
// Step 2: Send SIGTERM and wait
|
|
log.info("worker did not exit after shutdown RPC, sending SIGTERM");
|
|
await killWithSignal("SIGTERM", SIGTERM_GRACE_MS);
|
|
|
|
if (!childProcess) {
|
|
setStatus("stopped");
|
|
return;
|
|
}
|
|
|
|
// Step 3: Forcefully kill with SIGKILL
|
|
log.warn("worker did not exit after SIGTERM, sending SIGKILL");
|
|
await killWithSignal("SIGKILL", 2_000);
|
|
|
|
if (childProcess) {
|
|
log.error("worker process still alive after SIGKILL — this should not happen");
|
|
}
|
|
|
|
setStatus("stopped");
|
|
}
|
|
|
|
/**
|
|
* Wait for the child process to exit, up to `timeoutMs`.
|
|
* Resolves immediately if the process is already gone.
|
|
*/
|
|
function waitForExit(timeoutMs: number): Promise<void> {
|
|
return new Promise<void>((resolve) => {
|
|
if (!childProcess) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
let settled = false;
|
|
const timer = setTimeout(() => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve();
|
|
}, timeoutMs);
|
|
|
|
childProcess.once("exit", () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
function killWithSignal(
|
|
signal: NodeJS.Signals,
|
|
waitMs: number,
|
|
): Promise<void> {
|
|
return new Promise<void>((resolve) => {
|
|
if (!childProcess) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
const timer = setTimeout(() => {
|
|
resolve();
|
|
}, waitMs);
|
|
|
|
childProcess.once("exit", () => {
|
|
clearTimeout(timer);
|
|
resolve();
|
|
});
|
|
|
|
try {
|
|
childProcess.kill(signal);
|
|
} catch {
|
|
clearTimeout(timer);
|
|
resolve();
|
|
}
|
|
});
|
|
}
|
|
|
|
async function killProcess(): Promise<void> {
|
|
if (!childProcess) return;
|
|
intentionalStop = true;
|
|
try {
|
|
childProcess.kill("SIGKILL");
|
|
} catch {
|
|
// Process may already be dead
|
|
}
|
|
// Wait briefly for exit event
|
|
await new Promise<void>((resolve) => {
|
|
if (!childProcess) {
|
|
resolve();
|
|
return;
|
|
}
|
|
const timer = setTimeout(() => {
|
|
resolve();
|
|
}, 1_000);
|
|
childProcess.once("exit", () => {
|
|
clearTimeout(timer);
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// RPC call implementation
|
|
// -----------------------------------------------------------------------
|
|
|
|
function callInternal<M extends HostToWorkerMethodName>(
|
|
method: M,
|
|
params: HostToWorkerMethods[M][0],
|
|
timeoutMs?: number,
|
|
executeLogSink?: ExecuteLogSink,
|
|
meterDuplexWrite = false,
|
|
): Promise<HostToWorkerMethods[M][1]> {
|
|
const rpcPromise = new Promise<HostToWorkerMethods[M][1]>((resolve, reject) => {
|
|
if (!childProcess?.stdin?.writable) {
|
|
reject(
|
|
new Error(
|
|
`Cannot call "${method}" — worker for "${pluginId}" is not running`,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const id = nextRequestId++;
|
|
const timeout = resolveRpcCallTimeoutMs(timeoutMs, rpcTimeoutMs);
|
|
const invocationScope = deriveInvocationScope(method, params);
|
|
const invocation = invocationScope ? registerInvocation(invocationScope) : null;
|
|
// Register the host-owned execute route only for an execute call that
|
|
// carries a log sink. The company id comes from the host-derived
|
|
// invocation scope, never from the worker. This binds the sink to the
|
|
// exact company for the life of the call.
|
|
if (invocation && invocationScope && executeLogSink && method === "environmentExecute") {
|
|
registerExecuteRoute(invocation.id, invocationScope.companyId, executeLogSink);
|
|
}
|
|
|
|
// Guard against double-settlement. When a process exits all pending
|
|
// requests are rejected via rejectAllPending(), but the timeout timer
|
|
// may still be running. Without this guard the timer's reject fires on
|
|
// an already-settled promise, producing an unhandled rejection.
|
|
let settled = false;
|
|
|
|
const settle = <T>(fn: (value: T) => void, value: T): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
pendingRequests.delete(id);
|
|
clearInvocation(invocation);
|
|
clearExecuteRoute(invocation?.id);
|
|
fn(value);
|
|
};
|
|
|
|
const timer = setTimeout(() => {
|
|
settle(
|
|
reject,
|
|
new JsonRpcCallError({
|
|
code: PLUGIN_RPC_ERROR_CODES.TIMEOUT,
|
|
message: `RPC call "${method}" timed out after ${timeout}ms`,
|
|
}),
|
|
);
|
|
}, timeout);
|
|
|
|
const pending: PendingRequest = {
|
|
id,
|
|
method,
|
|
resolve: (response: JsonRpcResponse) => {
|
|
if (isJsonRpcSuccessResponse(response)) {
|
|
settle(resolve, response.result as HostToWorkerMethods[M][1]);
|
|
} else if ("error" in response && response.error) {
|
|
settle(reject, new JsonRpcCallError(response.error));
|
|
} else {
|
|
settle(reject, new Error(`Unexpected response format for "${method}"`));
|
|
}
|
|
},
|
|
timer,
|
|
sentAt: Date.now(),
|
|
invocationId: invocation?.id,
|
|
};
|
|
|
|
pendingRequests.set(id, pending);
|
|
|
|
try {
|
|
const request = {
|
|
...createRequest(method, params, id),
|
|
...(invocation ? { paperclipInvocation: invocation } : {}),
|
|
};
|
|
sendMessage(request, meterDuplexWrite);
|
|
} catch (err) {
|
|
clearTimeout(timer);
|
|
pendingRequests.delete(id);
|
|
clearInvocation(invocation);
|
|
clearExecuteRoute(invocation?.id);
|
|
if (err instanceof DuplexAggregateBytesExceededError) {
|
|
// The transport reservation failed before the write. Reject with the
|
|
// typed error unwrapped, so the duplex write caller ends the route
|
|
// fail-closed with the aggregate marker.
|
|
reject(err);
|
|
} else {
|
|
reject(
|
|
new Error(
|
|
`Failed to send "${method}" to worker: ${
|
|
err instanceof Error ? err.message : String(err)
|
|
}`,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Some call sites hand these promises across async boundaries before
|
|
// attaching their own handlers. Mark the promise as handled here so a
|
|
// worker-side JSON-RPC error can fail the caller without killing the host
|
|
// process via an unhandled rejection.
|
|
void rpcPromise.catch(() => undefined);
|
|
|
|
return rpcPromise;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Public API
|
|
// -----------------------------------------------------------------------
|
|
|
|
const handle: PluginWorkerHandle = {
|
|
get pluginId() {
|
|
return pluginId;
|
|
},
|
|
|
|
get status() {
|
|
return status;
|
|
},
|
|
|
|
get supportedMethods() {
|
|
return supportedMethods;
|
|
},
|
|
|
|
async start() {
|
|
await startInternal();
|
|
},
|
|
|
|
async stop() {
|
|
await stopInternal();
|
|
},
|
|
|
|
async restart() {
|
|
await stopInternal();
|
|
await startInternal();
|
|
},
|
|
|
|
call<M extends HostToWorkerMethodName>(
|
|
method: M,
|
|
params: HostToWorkerMethods[M][0],
|
|
timeoutMs?: number,
|
|
executeLogSink?: ExecuteLogSink,
|
|
): Promise<HostToWorkerMethods[M][1]> {
|
|
if (status !== "running" && status !== "starting") {
|
|
return Promise.reject(
|
|
new Error(
|
|
`Cannot call "${method}" — worker for "${pluginId}" is ${status}`,
|
|
),
|
|
);
|
|
}
|
|
return callInternal(method, params, timeoutMs, executeLogSink);
|
|
},
|
|
|
|
openSetupTokenPtySession(input: SetupTokenPtyOpenInput) {
|
|
if (status !== "running" && status !== "starting") {
|
|
return Promise.reject(
|
|
new Error(
|
|
`Cannot open a login pseudo-terminal — worker for "${pluginId}" is ${status}`,
|
|
),
|
|
);
|
|
}
|
|
return openSetupTokenPtySession(input);
|
|
},
|
|
|
|
openDuplexChannel(input: DuplexChannelOpenInput) {
|
|
if (status !== "running" && status !== "starting") {
|
|
return Promise.reject(
|
|
new Error(
|
|
`Cannot open a duplex channel — worker for "${pluginId}" is ${status}`,
|
|
),
|
|
);
|
|
}
|
|
return openDuplexChannel(input);
|
|
},
|
|
|
|
notify(method: string, params: unknown) {
|
|
if (status !== "running") return;
|
|
const invocationScope = deriveInvocationScope(method, params);
|
|
// Notifications have no response to settle on, so the invocation scope
|
|
// is GC'd by TTL. Call-path invocations are registered without a TTL and
|
|
// cleared on settlement, so they survive arbitrarily long call timeouts.
|
|
const invocation = invocationScope ? registerInvocation(invocationScope, MAX_RPC_TIMEOUT_MS) : null;
|
|
try {
|
|
sendMessage({
|
|
jsonrpc: JSONRPC_VERSION,
|
|
method,
|
|
params,
|
|
...(invocation ? { paperclipInvocation: invocation } : {}),
|
|
});
|
|
} catch {
|
|
clearInvocation(invocation);
|
|
log.warn({ method }, "failed to send notification to worker");
|
|
}
|
|
},
|
|
|
|
on<K extends WorkerHandleEventName>(
|
|
event: K,
|
|
listener: (payload: WorkerHandleEvents[K]) => void,
|
|
) {
|
|
emitter.on(event, listener);
|
|
},
|
|
|
|
off<K extends WorkerHandleEventName>(
|
|
event: K,
|
|
listener: (payload: WorkerHandleEvents[K]) => void,
|
|
) {
|
|
emitter.off(event, listener);
|
|
},
|
|
|
|
setProactiveCompanyScopes(companyIds: readonly string[]): void {
|
|
proactiveCompanyScopes.clear();
|
|
for (const id of companyIds) {
|
|
const trimmed = readNonEmptyString(id);
|
|
if (trimmed) proactiveCompanyScopes.add(trimmed);
|
|
}
|
|
},
|
|
|
|
diagnostics(): WorkerDiagnostics {
|
|
return {
|
|
pluginId,
|
|
status,
|
|
pid: childProcess?.pid ?? null,
|
|
uptime:
|
|
startedAt !== null && status === "running"
|
|
? Date.now() - startedAt
|
|
: null,
|
|
consecutiveCrashes,
|
|
totalCrashes,
|
|
pendingRequests: pendingRequests.size,
|
|
lastCrashAt,
|
|
nextRestartAt,
|
|
};
|
|
},
|
|
};
|
|
|
|
return handle;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Implementation: createPluginWorkerManager
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Options for creating a PluginWorkerManager.
|
|
*/
|
|
export interface PluginWorkerManagerOptions {
|
|
/**
|
|
* Optional callback invoked when a worker emits a lifecycle event
|
|
* (crash, restart). Used by the server to publish global live events.
|
|
*/
|
|
onWorkerEvent?: (event: {
|
|
type: "plugin.worker.crashed" | "plugin.worker.restarted";
|
|
pluginId: string;
|
|
code?: number | null;
|
|
signal?: string | null;
|
|
willRestart?: boolean;
|
|
}) => void;
|
|
/**
|
|
* The process-scoped aggregate ceiling for concurrent duplex channel routes,
|
|
* across every worker in the process. The manager builds one shared slot
|
|
* controller from it and injects it into every worker handle, so one tenant can
|
|
* never exhaust the manager-wide resource. The manager validates it and falls
|
|
* back to {@link DEFAULT_MAX_CONCURRENT_DUPLEX_ROUTES} for an absent or an
|
|
* invalid value. It is not the per-agent `heartbeat.maxConcurrentRuns`, which
|
|
* stays upstream admission only.
|
|
*/
|
|
maxConcurrentDuplexRoutes?: number | null;
|
|
/**
|
|
* The process-owned aggregate byte ledger. The process root creates one ledger
|
|
* from validated configuration and passes it here. The manager injects the same
|
|
* object into every worker handle, so one gauge bounds the aggregate retained
|
|
* bytes across every route in the process. When it is absent, the worker retains
|
|
* duplex bytes unbounded. The manager never makes a fresh default ledger.
|
|
*/
|
|
duplexAggregateByteLedger?: DuplexAggregateByteLedger | null;
|
|
}
|
|
|
|
/**
|
|
* The default process-scoped aggregate ceiling for concurrent duplex channel
|
|
* routes. It caps the manager-wide resource, not one agent's run budget. The host
|
|
* reports an explicit route-busy outcome when the ceiling is full.
|
|
*/
|
|
export const DEFAULT_MAX_CONCURRENT_DUPLEX_ROUTES = 128;
|
|
|
|
/**
|
|
* Build one process-scoped aggregate route-slot controller. The controller holds a
|
|
* strictly positive integer ceiling and a live count. `tryAcquire` reserves one
|
|
* slot only when a slot is free, so the count never passes the ceiling.
|
|
*/
|
|
export function createDuplexRouteSlotController(maxRoutes?: number | null): DuplexRouteSlotController {
|
|
const ceiling =
|
|
typeof maxRoutes === "number" && Number.isInteger(maxRoutes) && maxRoutes > 0
|
|
? maxRoutes
|
|
: DEFAULT_MAX_CONCURRENT_DUPLEX_ROUTES;
|
|
let active = 0;
|
|
return {
|
|
tryAcquire(): boolean {
|
|
if (active >= ceiling) return false;
|
|
active += 1;
|
|
return true;
|
|
},
|
|
release(): void {
|
|
if (active > 0) active -= 1;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a new PluginWorkerManager.
|
|
*
|
|
* The manager holds all plugin worker handles and provides a unified API for
|
|
* starting, stopping, and communicating with plugin workers.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const manager = createPluginWorkerManager();
|
|
*
|
|
* const handle = await manager.startWorker("acme.linear", {
|
|
* entrypointPath: "/path/to/worker.cjs",
|
|
* manifest,
|
|
* config: resolvedConfig,
|
|
* instanceInfo: { instanceId: "inst-1", hostVersion: "1.0.0" },
|
|
* apiVersion: 1,
|
|
* hostHandlers: { "config.get": async () => resolvedConfig, ... },
|
|
* });
|
|
*
|
|
* // Send RPC call to the worker
|
|
* const health = await manager.call("acme.linear", "health", {});
|
|
*
|
|
* // Shutdown all workers on server exit
|
|
* await manager.stopAll();
|
|
* ```
|
|
*/
|
|
export function createPluginWorkerManager(
|
|
managerOptions?: PluginWorkerManagerOptions,
|
|
): PluginWorkerManager {
|
|
const log = logger.child({ service: "plugin-worker-manager" });
|
|
const workers = new Map<string, PluginWorkerHandle>();
|
|
/** Per-plugin startup locks to prevent concurrent spawn races. */
|
|
const startupLocks = new Map<string, Promise<PluginWorkerHandle>>();
|
|
// The one shared, process-scoped aggregate route-slot controller. The manager
|
|
// injects it into every worker handle, so the duplex route ceiling counts every
|
|
// concurrent route across the process, not one agent's setting.
|
|
const duplexRouteSlots = createDuplexRouteSlotController(
|
|
managerOptions?.maxConcurrentDuplexRoutes,
|
|
);
|
|
// The one shared, process-owned aggregate byte ledger. The manager injects the
|
|
// same object into every worker handle, so one gauge bounds the aggregate
|
|
// retained bytes across every route in the process. It is `null` when the
|
|
// process root injected no ledger.
|
|
const duplexAggregateByteLedger = managerOptions?.duplexAggregateByteLedger ?? null;
|
|
|
|
return {
|
|
async startWorker(
|
|
pluginId: string,
|
|
options: WorkerStartOptions,
|
|
): Promise<PluginWorkerHandle> {
|
|
// Mutex: if a start is already in-flight for this plugin, wait for it
|
|
const inFlight = startupLocks.get(pluginId);
|
|
if (inFlight) {
|
|
log.warn({ pluginId }, "concurrent startWorker call — waiting for in-flight start");
|
|
return inFlight;
|
|
}
|
|
|
|
const existing = workers.get(pluginId);
|
|
if (existing && existing.status !== "stopped") {
|
|
throw new Error(
|
|
`Worker already registered for plugin "${pluginId}" (status: ${existing.status})`,
|
|
);
|
|
}
|
|
|
|
const handle = createPluginWorkerHandle(pluginId, {
|
|
// Inject the shared process-scoped route-slot controller and the shared
|
|
// process-owned aggregate byte ledger, unless the caller already supplied
|
|
// its own (a test may inject its own).
|
|
duplexRouteSlots,
|
|
duplexAggregateByteLedger,
|
|
...options,
|
|
});
|
|
workers.set(pluginId, handle);
|
|
|
|
// Subscribe to crash/ready events for live event forwarding
|
|
if (managerOptions?.onWorkerEvent) {
|
|
const notify = managerOptions.onWorkerEvent;
|
|
handle.on("crash", (payload) => {
|
|
notify({
|
|
type: "plugin.worker.crashed",
|
|
pluginId: payload.pluginId,
|
|
code: payload.code,
|
|
signal: payload.signal,
|
|
willRestart: payload.willRestart,
|
|
});
|
|
});
|
|
handle.on("ready", (payload) => {
|
|
// Only emit restarted if this was a crash recovery (totalCrashes > 0)
|
|
const diag = handle.diagnostics();
|
|
if (diag.totalCrashes > 0) {
|
|
notify({
|
|
type: "plugin.worker.restarted",
|
|
pluginId: payload.pluginId,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
log.info({ pluginId }, "starting plugin worker");
|
|
|
|
// Set the lock before awaiting start() to prevent concurrent spawns
|
|
const startPromise = handle.start().then(() => handle).finally(() => {
|
|
startupLocks.delete(pluginId);
|
|
});
|
|
startupLocks.set(pluginId, startPromise);
|
|
|
|
return startPromise;
|
|
},
|
|
|
|
async stopWorker(pluginId: string): Promise<void> {
|
|
const handle = workers.get(pluginId);
|
|
if (!handle) {
|
|
log.warn({ pluginId }, "no worker registered for plugin, nothing to stop");
|
|
return;
|
|
}
|
|
|
|
log.info({ pluginId }, "stopping plugin worker");
|
|
await handle.stop();
|
|
workers.delete(pluginId);
|
|
},
|
|
|
|
getWorker(pluginId: string): PluginWorkerHandle | undefined {
|
|
return workers.get(pluginId);
|
|
},
|
|
|
|
isRunning(pluginId: string): boolean {
|
|
const handle = workers.get(pluginId);
|
|
return handle?.status === "running";
|
|
},
|
|
|
|
setProactiveCompanyScopes(pluginId: string, companyIds: readonly string[]): void {
|
|
workers.get(pluginId)?.setProactiveCompanyScopes(companyIds);
|
|
},
|
|
|
|
async stopAll(): Promise<void> {
|
|
log.info({ count: workers.size }, "stopping all plugin workers");
|
|
const promises = Array.from(workers.values()).map(async (handle) => {
|
|
try {
|
|
await handle.stop();
|
|
} catch (err) {
|
|
log.error(
|
|
{
|
|
pluginId: handle.pluginId,
|
|
err: err instanceof Error ? err.message : String(err),
|
|
},
|
|
"error stopping worker during shutdown",
|
|
);
|
|
}
|
|
});
|
|
await Promise.all(promises);
|
|
workers.clear();
|
|
},
|
|
|
|
diagnostics(): WorkerDiagnostics[] {
|
|
return Array.from(workers.values()).map((h) => h.diagnostics());
|
|
},
|
|
|
|
call<M extends HostToWorkerMethodName>(
|
|
pluginId: string,
|
|
method: M,
|
|
params: HostToWorkerMethods[M][0],
|
|
timeoutMs?: number,
|
|
executeLogSink?: ExecuteLogSink,
|
|
): Promise<HostToWorkerMethods[M][1]> {
|
|
const handle = workers.get(pluginId);
|
|
if (!handle) {
|
|
return Promise.reject(
|
|
new Error(`No worker registered for plugin "${pluginId}"`),
|
|
);
|
|
}
|
|
return handle.call(method, params, timeoutMs, executeLogSink);
|
|
},
|
|
|
|
openSetupTokenPtySession(pluginId: string, input: SetupTokenPtyOpenInput) {
|
|
const handle = workers.get(pluginId);
|
|
if (!handle) {
|
|
return Promise.reject(
|
|
new Error(`No worker registered for plugin "${pluginId}"`),
|
|
);
|
|
}
|
|
return handle.openSetupTokenPtySession(input);
|
|
},
|
|
};
|
|
}
|