/// /** * Internal gRPC server implementing HashiCorp go-plugin protocol. * * This file is NEVER imported by plugin developers. * It handles: magic cookie check, proto loading, Unix socket server, * handshake output, and routing Execute calls to the plugin handler. * * ─── Hot reload contract ───────────────────────────────────────────── * * `bun --hot` re-evaluates the plugin entry file on every source change, * which means `createPlugin().start()` (and therefore startGrpcServer) * runs again in the SAME process. We must be idempotent or the second * bindAsync would fail with "address already in use". * * The trick: keep the gRPC server + state on `globalThis`, which bun * preserves across hot reloads. On the first call we bind the socket * once; subsequent calls swap the handler/manifest in place. The gRPC * service methods read from globalThis on every request, so swaps take * effect immediately without invalidating np-agent's existing gRPC * connection — the agent never notices a reload. */ import * as grpc from "@grpc/grpc-js"; import * as protoLoader from "@grpc/proto-loader"; import { readFileSync, unlinkSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { cfg } from "../config"; import { join } from "path"; import type { PluginHandler, ExecuteRequest, PluginManifest } from "../types"; // Embed the proto as text so it survives `bun build --compile` (a compiled // binary has no plugin.proto on disk next to the source). Works from source too. import protoSource from "./plugin.proto" with { type: "text" }; const MAGIC_COOKIE_KEY = "NP_AGENT_PLUGIN"; const MAGIC_COOKIE_VALUE = "np-agent-v1"; // workerServerCredentials returns mTLS credentials when the agent provided the // cert material (NP_WORKER_TLS_CA/CERT/KEY, inline PEM or *_FILE path) — the // server presents its cert and REQUIRES a client cert signed by the CA, so only // the agent that spawned it can call it. Falls back to insecure (unix-socket / // local dev) when no certs are present. No plugin-author involvement. function workerServerCredentials(): grpc.ServerCredentials { const read = (valEnv: string, fileEnv: string): Buffer | null => { const file = process.env[fileEnv]; if (file) return readFileSync(file); const val = process.env[valEnv]; if (val) return Buffer.from(val); return null; }; const ca = read("NP_WORKER_TLS_CA", "NP_WORKER_TLS_CA_FILE"); const cert = read("NP_WORKER_TLS_CERT", "NP_WORKER_TLS_CERT_FILE"); const key = read("NP_WORKER_TLS_KEY", "NP_WORKER_TLS_KEY_FILE"); if (ca && cert && key) { // 3rd arg true = require + verify the client certificate against `ca`. return grpc.ServerCredentials.createSsl(ca, [{ private_key: key, cert_chain: cert }], true); } return grpc.ServerCredentials.createInsecure(); } // ─── Proto-level types ────────────────────────────────────────────── // // Shapes of the wire messages as they come off @grpc/proto-loader with // `keepCase: true`. Fields are snake_case (matching plugin.proto); the // public ExecuteRequest type (from ../types) is the camelCase version // we expose to plugin authors. Narrowing at the edges keeps the `any` // from bleeding into the rest of the file. interface ProtoExecuteRequest { message_id: string; command_type: string; payload: Buffer | string; supports_partial: boolean; can_cancel: boolean; reply_to: string; action_type?: string; } interface ProtoExecuteEvent { started?: Record; output?: { stdout: string; stderr: string; data: Buffer; }; result?: { success: boolean; data: Buffer; error: string; exit_code: number; error_code: string; }; } interface ProtoEmpty { // Capabilities / Health / Version all take empty request messages. } interface ProtoCapabilitiesResponse { command_types: string[]; priority: number; } interface ProtoHealthResponse { healthy: boolean; uptime_ms?: string; active_commands?: number; memory_bytes?: string; message?: string; } interface ProtoVersionResponse { name: string; version: string; metadata: Record; } // Expected shape of the loaded proto descriptor. @grpc/proto-loader // returns a generic GrpcObject; we cast to this once so every subsequent // access is typed. interface LoadedProto { npagent: { plugin: { v1: { ExecutorPlugin: grpc.ServiceClientConstructor & { service: grpc.ServiceDefinition; }; }; }; }; } // ─── Cross-reload state ───────────────────────────────────────────── interface PluginState { server: grpc.Server; socketPath: string; startTime: number; activeCommands: number; handler: PluginHandler; manifest: PluginManifest; } declare global { // eslint-disable-next-line no-var var __nullplatformPluginState: PluginState | undefined; // eslint-disable-next-line no-var var __nullplatformConnGuard: boolean | undefined; } export function startGrpcServer( handler: PluginHandler, manifest: PluginManifest, ): void { // Magic cookie check is cheap and idempotent — safe to run on every call. if (process.env[MAGIC_COOKIE_KEY] !== MAGIC_COOKIE_VALUE) { console.error( "This binary is a plugin. It is not meant to be executed directly.\n" + `Set ${MAGIC_COOKIE_KEY}=${MAGIC_COOKIE_VALUE} to run.`, ); process.exit(1); } // A TLS listener is probed by bare-TCP checks (k8s TCPSocket readiness) and // scanned by anything that can reach the port; those abort the TLS handshake // and Node surfaces them as PROCESS-level socket errors. A server must survive // connection noise — swallow known-transient socket errors, exit on anything // real. Installed once per process (survives bun --hot). if (!globalThis.__nullplatformConnGuard) { globalThis.__nullplatformConnGuard = true; const transient = /ECONNRESET|EPIPE|ECONNABORTED|socket hang up|handshake|ssl|tls/i; process.on("uncaughtException", (err: NodeJS.ErrnoException) => { if (transient.test(err?.code ?? "") || transient.test(err?.message ?? "")) { return; // benign connection noise (probes, scans, aborted handshakes) } console.error(`[worker] fatal: ${err?.stack ?? err}`); process.exit(1); }); } // Hot reload path: server already bound in this process. Swap the // handler/manifest into the existing state object so subsequent gRPC // requests pick up the new code. No re-bind, no re-handshake, no // duplicate signal handlers — the agent's connection stays live. if (globalThis.__nullplatformPluginState) { const existing = globalThis.__nullplatformPluginState; existing.handler = handler; existing.manifest = manifest; console.error(`[${manifest.name}] hot reload: handler swapped`); return; } // First start in this process: create state, load proto, add service, // bind the socket, print handshake, register signal handlers. Each // step runs exactly once per process lifetime regardless of reloads. const state: PluginState = { server: new grpc.Server(), socketPath: join( tmpdir(), `np-plugin-${manifest.name}-${process.pid}.sock`, ), startTime: Date.now(), activeCommands: 0, handler, manifest, }; // @grpc/proto-loader loads from a file path, so materialize the embedded proto // text to a temp file. This is the one path that works both from source and // from a compiled single-file binary. const protoPath = join(tmpdir(), `np-plugin-${process.pid}.proto`); writeFileSync(protoPath, protoSource); const packageDefinition = protoLoader.loadSync(protoPath, { keepCase: true, longs: String, enums: String, defaults: true, oneofs: true, }); const proto = grpc.loadPackageDefinition(packageDefinition) as unknown as LoadedProto; state.server.addService(proto.npagent.plugin.v1.ExecutorPlugin.service, { Execute: executeHandler, Capabilities: capabilitiesHandler, Health: healthHandler, Version: versionHandler, }); // Two ways to serve: // - TCP (NP_GRPC_LISTEN set): the worker runs as its own pod/container and the // orchestrating agent dials it over the network. No handshake — the agent // assigned the address and uses the Health RPC for readiness. // - unix socket (default): the agent spawns this as a child and reads the // go-plugin handshake line to discover the socket. const tcpListen = cfg.grpcListen(); const bindTarget = tcpListen ? tcpListen : `unix://${state.socketPath}`; // Remove stale socket from a previous crash (PID reuse is rare but possible). if (!tcpListen) { try { unlinkSync(state.socketPath); } catch {} } // TCP workers use mTLS when the agent supplied certs; unix sockets stay local. const creds = tcpListen ? workerServerCredentials() : grpc.ServerCredentials.createInsecure(); state.server.bindAsync( bindTarget, creds, (err) => { if (err) { console.error(`[${manifest.name}] gRPC bind failed: ${err.message}`); process.exit(1); } // Only publish state after bind succeeds — a failed bind must not // leave a stale globalThis that the hot-reload path would trust. globalThis.__nullplatformPluginState = state; if (tcpListen) { const mtls = !!(process.env.NP_WORKER_TLS_CA || process.env.NP_WORKER_TLS_CA_FILE); console.error(`[${manifest.name}] gRPC listening on ${bindTarget} (tcp, ${mtls ? "mTLS" : "insecure"})`); } else { process.stdout.write(`1|1|unix|${state.socketPath}|grpc|\n`); console.error(`[${manifest.name}] gRPC listening on ${state.socketPath}`); } }, ); const shutdown = () => state.server.tryShutdown(() => { if (!tcpListen) { try { unlinkSync(state.socketPath); } catch {} } process.exit(0); }); process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); } // ─── gRPC service methods ──────────────────────────────────────────── // // Defined at module scope (not inside startGrpcServer) so they read // `globalThis.__nullplatformPluginState` on every invocation. A hot-swapped // handler/manifest takes effect on the very next request without needing // to re-register the service or rebind the server. function executeHandler( call: grpc.ServerWritableStream, ): void { const state = globalThis.__nullplatformPluginState; if (!state) { // Impossible in practice (service is only registered after state is set), // but bail out cleanly rather than crash if it ever happens. call.end(); return; } state.activeCommands++; const raw = call.request; const req: ExecuteRequest = { messageId: raw.message_id, commandType: raw.command_type, payload: typeof raw.payload === "string" ? Buffer.from(raw.payload, "base64") : raw.payload, supportsPartial: raw.supports_partial, canCancel: raw.can_cancel, replyTo: raw.reply_to, actionType: raw.action_type || "", emit(output) { call.write({ output: { stdout: output.stdout ?? "", stderr: output.stderr ?? "", data: output.data ? Buffer.from(JSON.stringify(output.data)) : Buffer.alloc(0), }, }); }, }; call.write({ started: {} }); state.handler .execute(req) .then((result) => { call.write({ result: { success: result.success, data: result.data ? Buffer.from(JSON.stringify(result.data)) : Buffer.alloc(0), error: result.error || "", exit_code: result.exitCode || 0, error_code: result.errorCode || "", }, }); }) .catch((err) => { call.write({ result: { success: false, data: Buffer.alloc(0), error: err instanceof Error ? err.message : String(err), exit_code: 1, error_code: "", }, }); }) .finally(() => { const s = globalThis.__nullplatformPluginState; if (s) s.activeCommands--; call.end(); }); } // Capabilities / Health / Version take an empty request, so only the // callback argument is used. The `call` parameter is positional in the // gRPC service signature — we have to name it to reach `callback`. function capabilitiesHandler( call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData, ): void { const state = globalThis.__nullplatformPluginState; callback(null, { command_types: state?.manifest.command_types ?? [], priority: 100, }); } function healthHandler( call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData, ): void { const state = globalThis.__nullplatformPluginState; if (!state) { callback(null, { healthy: false, message: "not started" }); return; } callback(null, { healthy: true, uptime_ms: String(Date.now() - state.startTime), active_commands: state.activeCommands, memory_bytes: String( typeof Bun !== "undefined" ? (process.memoryUsage as unknown as { rss?: () => number }).rss?.() ?? process.memoryUsage().rss : process.memoryUsage().rss, ), message: "ok", }); } function versionHandler( call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData, ): void { const state = globalThis.__nullplatformPluginState; callback(null, { name: state?.manifest.name ?? "", version: state?.manifest.version ?? "", metadata: { runtime: typeof Bun !== "undefined" ? "bun" : "node", bun_version: typeof Bun !== "undefined" ? Bun.version : "n/a", }, }); }