Files
HiveGrid-Wireframes/hivegrid-cloud/apps/worker/src/jobs/plugin-invoke-bootstrap.ts
T

651 lines
22 KiB
TypeScript

/**
* Phase 06 #21 — Production wiring for the plugin-invoke worker handler.
*
* `handlePluginInvokeJob` lives in `./plugin-invoke.ts` and is fully
* dependency-injected. This module composes the real production seams:
*
* - `loadPlugin` → Prisma `Plugin.findUnique`
* - `loadBinary` → filesystem read from `PLUGIN_BINARY_BASE_PATH`
* - `verifySignature` → `verifyPluginSignature` from plugin-runtime
* - `consumeRateLimit` → SharedStore sliding-window counter
* - `invokeInSandbox` → worker-thread sandbox + capability gateway
* - `recordAudit` → Prisma `PluginInvocation.create` + structured logger
* - `trustedPublicKeysHex` → parsed once from `PLUGIN_TRUSTED_PUBLIC_KEYS`
* - `recordOutcomeForCrashTracking` → `createCrashTracker` adapter
*
* Closes F-015 (Critical) — before this bootstrap, `handlePluginInvokeJob`
* was not wired into production; the `plugin-invoke` queue accepted jobs
* but the handler at `handlers.ts:752` was a silent no-op stub.
*
* Out of scope for this bootstrap (tracked in plugin-runtime deviations):
* - host-fn implementations for higher-risk capabilities. The safe
* `log` and `audit` imports are wired; network, storage, secret,
* tenant.info and DB bindings remain fail-closed until reviewed.
* - hot-reload of binaries / manifests
*/
import { promises as fs } from "node:fs";
import * as path from "node:path";
import {
PluginCapabilityUseAuditTruncatedError,
createCrashTracker,
createPluginCapabilityUseRecorder,
assertPluginRuntimeProductionEnabled,
parsePluginManifest,
parseTrustedPublicKeys,
verifyPluginSignature,
type CrashTrackerStore,
type PluginCapabilityGrant,
type PluginCapabilityUseBatch,
type PluginInvocationResult,
} from "@toolctrl/plugin-runtime";
import { AUDIT_EVENT_OUTBOX_TYPE, buildAuditEventCreateData } from "@toolctrl/db/audit-events.server";
import type { SharedStore } from "@toolctrl/shared-store";
import type {
PluginInvokeDeps,
PluginInvokeOutcome,
PluginInvokeRow,
RateLimitDecision,
} from "./plugin-invoke.js";
import { PLUGIN_CAPABILITY_USE_EVENT_TYPE } from "./plugin-capability-use.js";
import { invokePluginSandboxInWorker } from "./plugin-sandbox-isolated.js";
export type PluginInvokeBootstrapLogger = {
info: (fields: Record<string, unknown>, msg: string) => void;
warn?: (fields: Record<string, unknown>, msg?: string) => void;
error?: (fields: Record<string, unknown>, msg?: string) => void;
};
export type PluginInvokeBootstrapPrismaSurface = {
plugin: {
findUnique: (args: {
where: { tenantId_pluginId: { tenantId: string; pluginId: string } };
}) => Promise<{
tenantId: string;
pluginId: string;
version: string;
manifest: unknown;
wasmSha256: string;
signaturePublicKey: string;
enabled: boolean;
} | null>;
update: (args: {
where: { tenantId_pluginId: { tenantId: string; pluginId: string } };
data: { enabled: boolean };
}) => Promise<unknown>;
};
$transaction?: (fn: (tx: PluginInvokeBootstrapPrismaSurface) => Promise<unknown>) => Promise<unknown>;
outboxEvent: {
create: (args: {
data: {
tenantId: string | null;
eventType: string;
payload: unknown;
dispatchKey: string;
priority?: number;
};
}) => Promise<unknown>;
findUnique: (args: {
where: { dispatchKey: string };
}) => Promise<unknown | null>;
};
pluginInvocation: {
create: (args: {
data: {
id: string;
tenantId: string;
pluginId: string;
version: string;
hookName: string;
outcome: string;
failureReason?: string | null;
message?: string | null;
durationMs: number;
fuelUsed?: number | null;
};
}) => Promise<unknown>;
};
tenantPluginCapabilityGrant: {
findMany: (args: {
where: { tenantId: string; pluginId: string };
select: { capabilityName: true; scopeJson: true };
}) => Promise<Array<{ capabilityName: string; scopeJson: unknown }>>;
};
};
export type PluginInvokeBootstrapOptions = {
env: NodeJS.ProcessEnv;
prisma: PluginInvokeBootstrapPrismaSurface;
store: SharedStore;
logger: PluginInvokeBootstrapLogger;
};
/** Default base path for plugin binaries on disk. */
const DEFAULT_PLUGIN_BINARY_BASE_PATH = "/app/var/plugins";
const DEFAULT_RATE_LIMIT_PER_MINUTE = 100;
const DEFAULT_RATE_LIMIT_WINDOW_SECONDS = 60;
const DEFAULT_PLUGIN_MEMORY_LIMIT_MB = 64;
const DEFAULT_PLUGIN_FUEL_LIMIT = 1_000_000;
const DEFAULT_PLUGIN_WALL_CLOCK_MS = 5_000;
const DEFAULT_PLUGIN_CAPABILITY_AUDIT_MAX_ENTRIES = 2_048;
const DEFAULT_PLUGIN_AUTO_DISABLE_THRESHOLD = 10;
const DEFAULT_PLUGIN_AUTO_DISABLE_WINDOW_MS = 600_000;
function resolveIntEnv(env: NodeJS.ProcessEnv, key: string, fallback: number): number {
const raw = env[key];
if (typeof raw !== "string" || raw.length === 0) return fallback;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return parsed;
}
/**
* Thin pass-through from SharedStore (`incr`, `expire`, `get`, `set`,
* `del`) to the CrashTracker's expected store contract. The shapes are
* already aligned; the wrapper exists so callers can swap in a fake
* store in tests without dragging SharedStore in.
*/
function adaptCrashTrackerStore(store: SharedStore): CrashTrackerStore {
return {
incr: (key, by = 1) => store.incr(key, by),
expire: (key, ttlSeconds) => store.expire(key, ttlSeconds),
get: (key) => store.get(key),
set: async (key, value, opts) => store.set(key, value, opts?.ttlSeconds ? { ttlSeconds: opts.ttlSeconds } : undefined),
del: (key) => store.del(key),
};
}
/**
* Sliding-window rate limiter keyed by `(pluginId, tenantId)`. Uses the
* SharedStore counter primitive — atomic incr + ttl on first hit.
*/
function makeRateLimiter(args: {
store: SharedStore;
perMinute: number;
windowSeconds: number;
}): PluginInvokeDeps["consumeRateLimit"] {
return async ({ pluginId, tenantId }): Promise<RateLimitDecision> => {
const key = `plugin-ratelimit-${pluginId}-${tenantId}`;
const count = await args.store.incr(key, 1);
if (count === 1) {
await args.store.expire(key, args.windowSeconds);
}
const allowed = count <= args.perMinute;
return { allowed, remaining: Math.max(0, args.perMinute - count) };
};
}
/**
* Filesystem loader. Reads `<base>/<pluginId>/<version>/plugin.wasm`.
* Returns null on ENOENT so the handler can route to `binary_load_failed`
* cleanly; any other error propagates (BullMQ retries).
*/
function makeBinaryLoader(args: {
basePath: string;
logger: PluginInvokeBootstrapLogger;
}): PluginInvokeDeps["loadBinary"] {
const basePath = path.resolve(args.basePath);
return async ({ pluginId, version }) => {
const filePath = path.resolve(basePath, pluginId, version, "plugin.wasm");
const relativePath = path.relative(basePath, filePath);
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
args.logger.warn?.({ pluginId, version }, "plugin.invoke.binary_path_escape_blocked");
return null;
}
try {
const buf = await fs.readFile(filePath);
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
args.logger.warn?.({ pluginId, version, filePath }, "plugin.invoke.binary_missing_on_disk");
return null;
}
throw err;
}
};
}
/**
* Prisma adapter. Reads the Plugin row + JSON manifest. Returns null on
* miss; the handler then short-circuits to `plugin_not_found`.
*/
function makePluginLoader(
prisma: PluginInvokeBootstrapPrismaSurface,
logger: PluginInvokeBootstrapLogger
): PluginInvokeDeps["loadPlugin"] {
return async ({ tenantId, pluginId, version }) => {
const row = await prisma.plugin.findUnique({
where: { tenantId_pluginId: { tenantId, pluginId } },
});
if (!row) return null;
if (row.version !== version) return null;
const parsed = parsePluginManifest(row.manifest);
if (!parsed.ok) {
logger.warn?.({ tenantId, pluginId, version, issues: parsed.issues }, "plugin.invoke.stored_manifest_invalid");
return null;
}
return {
tenantId: row.tenantId,
pluginId: row.pluginId,
version: row.version,
manifest: parsed.manifest,
wasmSha256: row.wasmSha256,
signaturePublicKey: row.signaturePublicKey,
enabled: row.enabled,
} satisfies PluginInvokeRow;
};
}
function makeCapabilityGrantLoader(
prisma: PluginInvokeBootstrapPrismaSurface
): PluginInvokeDeps["loadCapabilityGrants"] {
return async ({ tenantId, pluginId }): Promise<PluginCapabilityGrant[]> => {
const rows = await prisma.tenantPluginCapabilityGrant.findMany({
where: { tenantId, pluginId },
select: { capabilityName: true, scopeJson: true },
});
return rows.map((row) => ({
capabilityName: row.capabilityName,
scopeJson: row.scopeJson,
}));
};
}
function makeCapabilityUseSink(args: {
prisma: PluginInvokeBootstrapPrismaSurface;
logger: PluginInvokeBootstrapLogger;
}) {
return {
emit: async (batch: PluginCapabilityUseBatch): Promise<void> => {
const dispatchKey = `plugin-capability-use:${batch.pluginInvocationId}`;
try {
await args.prisma.outboxEvent.create({
data: {
tenantId: batch.tenantId,
eventType: PLUGIN_CAPABILITY_USE_EVENT_TYPE,
payload: batch,
dispatchKey,
priority: 10,
},
});
} catch (error) {
if (!isUniqueConstraintViolation(error)) {
throw error;
}
const existing = await args.prisma.outboxEvent.findUnique({
where: { dispatchKey },
});
if (!existing) {
throw error;
}
}
args.logger.info(
{
tenantId: batch.tenantId,
pluginId: batch.pluginId,
pluginInvocationId: batch.pluginInvocationId,
entryCount: batch.entries.length,
},
"plugin.invoke.capability_use_enqueued"
);
},
};
}
type JsonPrimitive = string | number | boolean | null;
type JsonSafeValue = JsonPrimitive | JsonSafeValue[] | { [key: string]: JsonSafeValue };
const MAX_AUDIT_METADATA_DEPTH = 8;
function toJsonSafeValue(value: unknown, depth = 0): JsonSafeValue {
if (depth > MAX_AUDIT_METADATA_DEPTH) {
return "[TRUNCATED]";
}
if (value === null || typeof value === "string" || typeof value === "boolean") {
return value;
}
if (typeof value === "number") {
return Number.isFinite(value) ? value : String(value);
}
if (typeof value === "bigint") {
return value.toString();
}
if (value instanceof Date) {
return value.toISOString();
}
if (Array.isArray(value)) {
return value.map((entry) => toJsonSafeValue(entry, depth + 1));
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.filter(([, entry]) => typeof entry !== "undefined" && typeof entry !== "function" && typeof entry !== "symbol")
.map(([key, entry]) => [key, toJsonSafeValue(entry, depth + 1)])
);
}
return String(value);
}
async function enqueuePluginHostAuditEvent(args: {
prisma: PluginInvokeBootstrapPrismaSurface;
logger: PluginInvokeBootstrapLogger;
tenantId: string;
pluginId: string;
version: string;
hookName: string;
pluginInvocationId: string;
sequence: number;
hostRecord: Record<string, unknown>;
}): Promise<void> {
const payload = buildAuditEventCreateData({
tenantId: args.tenantId,
actorId: args.pluginId,
actorType: "plugin",
action: "plugin.audit.recorded",
targetType: "plugin",
targetId: args.pluginId,
requestId: args.pluginInvocationId,
sourceModule: "worker.plugin-runtime",
outcome: "success",
dataSensitivity: "internal",
regulatoryScope: ["soc2"],
metadata: {
pluginId: args.pluginId,
pluginManifestId: typeof args.hostRecord.pluginId === "string" ? args.hostRecord.pluginId : args.pluginId,
pluginInvocationId: args.pluginInvocationId,
version: args.version,
hookName: args.hookName,
hostBinding: "audit",
sequence: args.sequence,
value: toJsonSafeValue(args.hostRecord.value),
},
});
const dispatchKey = `plugin-host-audit:${args.pluginInvocationId}:${args.sequence}`;
try {
await args.prisma.outboxEvent.create({
data: {
tenantId: args.tenantId,
eventType: AUDIT_EVENT_OUTBOX_TYPE,
payload,
dispatchKey,
priority: 20,
},
});
} catch (error) {
if (!isUniqueConstraintViolation(error)) {
throw error;
}
const existing = await args.prisma.outboxEvent.findUnique({
where: { dispatchKey },
});
if (!existing) {
throw error;
}
}
args.logger.info(
{
tenantId: args.tenantId,
pluginId: args.pluginId,
version: args.version,
hookName: args.hookName,
pluginInvocationId: args.pluginInvocationId,
sequence: args.sequence,
},
"plugin.invoke.host.audit_enqueued"
);
}
async function enqueuePluginAutoDisableAudit(args: {
prisma: PluginInvokeBootstrapPrismaSurface;
logger: PluginInvokeBootstrapLogger;
tenantId: string;
pluginId: string;
failureCount: number;
reason: string;
correlationId: string;
}): Promise<void> {
const payload = buildAuditEventCreateData({
tenantId: args.tenantId,
actorType: "system",
action: "plugin.auto_disabled",
targetType: "plugin",
targetId: args.pluginId,
sourceModule: "worker.plugin-runtime",
outcome: "blocked",
dataSensitivity: "internal",
regulatoryScope: ["soc2"],
metadata: {
pluginId: args.pluginId,
failureCount: args.failureCount,
reason: args.reason,
pluginInvocationId: args.correlationId,
trigger: "crash-threshold",
},
});
const dispatchKey = `plugin-auto-disable-audit:${args.tenantId}:${args.pluginId}:${args.correlationId}`;
try {
await args.prisma.outboxEvent.create({
data: {
tenantId: args.tenantId,
eventType: AUDIT_EVENT_OUTBOX_TYPE,
payload,
dispatchKey,
priority: 20,
},
});
} catch (error) {
if (!isUniqueConstraintViolation(error)) {
throw error;
}
const existing = await args.prisma.outboxEvent.findUnique({
where: { dispatchKey },
});
if (!existing) {
throw error;
}
}
args.logger.info(
{
tenantId: args.tenantId,
pluginId: args.pluginId,
failureCount: args.failureCount,
reason: args.reason,
},
"plugin.crash.auto_disable_audit_enqueued"
);
}
function isUniqueConstraintViolation(error: unknown): boolean {
return typeof error === "object" && error !== null && (error as { code?: unknown }).code === "P2002";
}
/**
* Sandbox adapter. Runs V8 WebAssembly in a killable worker thread and replays
* safe sync `log` and `audit` host imports back into the parent process for
* logging + capability-use auditing. Higher-risk host bindings remain absent
* and fail closed with `no_host_implementation`.
*/
function makeSandboxInvoker(args: {
env: NodeJS.ProcessEnv;
prisma: PluginInvokeBootstrapPrismaSurface;
logger: PluginInvokeBootstrapLogger;
capabilityUseSink: ReturnType<typeof makeCapabilityUseSink>;
}): PluginInvokeDeps["invokeInSandbox"] {
const memoryLimitMb = resolveIntEnv(args.env, "PLUGIN_MAX_MEMORY_MB", DEFAULT_PLUGIN_MEMORY_LIMIT_MB);
const fuelLimit = resolveIntEnv(args.env, "PLUGIN_MAX_FUEL", DEFAULT_PLUGIN_FUEL_LIMIT);
const wallClockMs = resolveIntEnv(args.env, "PLUGIN_MAX_WALL_CLOCK_MS", DEFAULT_PLUGIN_WALL_CLOCK_MS);
const maxCapabilityCallEntries = resolveIntEnv(
args.env,
"PLUGIN_CAPABILITY_AUDIT_MAX_ENTRIES",
DEFAULT_PLUGIN_CAPABILITY_AUDIT_MAX_ENTRIES
);
return async ({ pluginInvocationId, tenantId, pluginId, wasmBuffer, manifest, capabilityGrants, hookName, payload }): Promise<PluginInvocationResult> => {
const capabilityRecorder = createPluginCapabilityUseRecorder({
pluginInvocationId,
tenantId,
pluginId,
sink: args.capabilityUseSink,
logger: args.logger,
failClosed: true,
});
try {
const result = await invokePluginSandboxInWorker({
wasmBuffer,
manifest,
capabilityGrants,
hookName,
payload,
memoryLimitMb,
fuelLimit,
maxCapabilityCallEntries,
wallClockMs,
});
if (result.droppedCapabilityCallCount > 0) {
throw new PluginCapabilityUseAuditTruncatedError(pluginInvocationId, result.droppedCapabilityCallCount, result.capabilityCalls.length);
}
for (const info of result.capabilityCalls) {
capabilityRecorder.record(info);
}
let auditSequence = 0;
for (const entry of result.hostLogs) {
if (entry.level === "warn") {
args.logger.warn?.(entry.record, entry.message);
} else {
args.logger.info(entry.record, entry.message);
}
if (entry.message === "plugin.invoke.host.audit") {
await enqueuePluginHostAuditEvent({
prisma: args.prisma,
logger: args.logger,
tenantId,
pluginId,
version: manifest.version,
hookName,
pluginInvocationId,
sequence: auditSequence,
hostRecord: entry.record,
});
auditSequence += 1;
}
}
return result.result;
} finally {
await capabilityRecorder.flush();
}
};
}
/**
* Compose the production PluginInvokeDeps from env + Prisma + SharedStore
* + logger. Caller (`apps/worker/src/index.ts`) injects the returned
* object into `extraHandlerDependencies.pluginInvokeDeps`.
*/
export function bootstrapPluginInvokeDeps(options: PluginInvokeBootstrapOptions): PluginInvokeDeps {
const { env, prisma, store, logger } = options;
assertPluginRuntimeProductionEnabled(env);
const basePath = env.PLUGIN_BINARY_BASE_PATH?.trim() || DEFAULT_PLUGIN_BINARY_BASE_PATH;
const perMinute = resolveIntEnv(env, "PLUGIN_RATE_LIMIT_PER_MINUTE", DEFAULT_RATE_LIMIT_PER_MINUTE);
const windowSeconds = resolveIntEnv(env, "PLUGIN_RATE_LIMIT_WINDOW_SECONDS", DEFAULT_RATE_LIMIT_WINDOW_SECONDS);
const autoDisableThreshold = resolveIntEnv(env, "PLUGIN_AUTO_DISABLE_THRESHOLD", DEFAULT_PLUGIN_AUTO_DISABLE_THRESHOLD);
const autoDisableWindowMs = resolveIntEnv(env, "PLUGIN_AUTO_DISABLE_WINDOW_MS", DEFAULT_PLUGIN_AUTO_DISABLE_WINDOW_MS);
const trustedPublicKeysHex = parseTrustedPublicKeys(env.PLUGIN_TRUSTED_PUBLIC_KEYS);
const capabilityUseSink = makeCapabilityUseSink({ prisma, logger });
const crashTracker = createCrashTracker({
store: adaptCrashTrackerStore(store),
disablePlugin: async ({ pluginId, tenantId, failureCount, reason, correlationId }) => {
logger.warn?.({ pluginId, tenantId, failureCount, reason }, "plugin.crash.auto_disable_requested");
const disableAndAudit = async (client: PluginInvokeBootstrapPrismaSurface) => {
await client.plugin.update({
where: { tenantId_pluginId: { tenantId, pluginId } },
data: { enabled: false },
});
await enqueuePluginAutoDisableAudit({
prisma: client,
logger,
tenantId,
pluginId,
failureCount,
reason,
correlationId,
});
};
if (typeof prisma.$transaction === "function") {
await prisma.$transaction(disableAndAudit);
} else {
await disableAndAudit(prisma);
}
},
notify: async ({ pluginId, tenantId, failureCount, reason }) => {
logger.error?.({ pluginId, tenantId, failureCount, reason }, "plugin.crash.threshold_exceeded");
},
options: {
logger,
threshold: autoDisableThreshold,
windowMs: autoDisableWindowMs,
},
});
return {
loadPlugin: makePluginLoader(prisma, logger),
loadCapabilityGrants: makeCapabilityGrantLoader(prisma),
loadBinary: makeBinaryLoader({ basePath, logger }),
verifySignature: ({ wasmBuffer, manifest, trustedPublicKeysHex: trusted }) =>
verifyPluginSignature({
wasmBuffer,
expectedWasmSha256: manifest.wasmSha256,
signatureHex: manifest.signature,
publicKeyHex: manifest.signedBy,
trustedPublicKeysHex: trusted,
manifest,
}),
consumeRateLimit: makeRateLimiter({ store, perMinute, windowSeconds }),
invokeInSandbox: makeSandboxInvoker({ env, prisma, logger, capabilityUseSink }),
recordAudit: async ({ tenantId, pluginId, version, hookName, pluginInvocationId, outcome }) => {
await prisma.pluginInvocation.create({
data: {
id: pluginInvocationId,
tenantId,
pluginId,
version,
hookName,
outcome: outcome.ok ? "success" : "failure",
failureReason: outcome.ok ? null : outcome.reason,
message: outcome.ok ? null : outcome.message ?? null,
durationMs: outcome.durationMs,
fuelUsed: outcome.ok ? outcome.fuelUsed : null,
},
});
if (outcome.ok) {
logger.info(
{ tenantId, pluginId, version, hookName, pluginInvocationId, fuelUsed: outcome.fuelUsed, durationMs: outcome.durationMs },
"plugin.invoke.audit.success"
);
} else {
logger.error?.(
{ tenantId, pluginId, version, hookName, pluginInvocationId, reason: outcome.reason, message: outcome.message, durationMs: outcome.durationMs },
"plugin.invoke.audit.failure"
);
}
},
trustedPublicKeysHex,
recordOutcomeForCrashTracking: async ({ tenantId, pluginId, outcome }) => {
if (outcome.ok) {
await crashTracker.recordSuccess({ pluginId, tenantId });
} else {
await crashTracker.recordFailure({
pluginId,
tenantId,
reason: outcome.reason,
correlationId: outcome.pluginInvocationId,
});
}
},
logger,
} satisfies PluginInvokeDeps;
}
export type { PluginInvokeOutcome };