import type { Db } from "@paperclipai/db"; import { activityLog, agentTaskSessions as agentTaskSessionsTable, agents as agentsTable, budgetIncidents, companyMemberships, costEvents, heartbeatRuns, invites, issues as issuesTable, pluginLogs, principalPermissionGrants, projects as projectsTable, } from "@paperclipai/db"; import { eq, and, like, desc, inArray, sql, isNull, isNotNull, gt, lte } from "drizzle-orm"; import type { HostServices, Company, Agent, Project, Issue, Goal, PluginWorkspace, IssueComment, PluginIssueAssigneeSummary, PluginIssueOrchestrationSummary, PluginExecutionWorkspaceMetadata, } from "@paperclipai/plugin-sdk"; import type { CreateIssueThreadInteraction, InviteJoinType, IssueDocumentSummary, PermissionKey, PrincipalType } from "@paperclipai/shared"; import { pluginOperationIssueOriginKind } from "@paperclipai/shared"; import { companyService } from "./companies.js"; import { agentService } from "./agents.js"; import { projectService } from "./projects.js"; import { executionWorkspaceService } from "./execution-workspaces.js"; import { issueService } from "./issues.js"; import { issueThreadInteractionService } from "./issue-thread-interactions.js"; import { goalService } from "./goals.js"; import { documentService } from "./documents.js"; import { heartbeatService } from "./heartbeat.js"; import { budgetService } from "./budgets.js"; import { issueApprovalService } from "./issue-approvals.js"; import { approvalService } from "./approvals.js"; import { getStorageService } from "../storage/index.js"; import { subscribeCompanyLiveEvents } from "./live-events.js"; import { createHash, randomBytes, randomUUID } from "node:crypto"; import path from "node:path"; import { pluginRegistryService } from "./plugin-registry.js"; import { pluginStateStore } from "./plugin-state-store.js"; import { pluginDatabaseService } from "./plugin-database.js"; import { pluginManagedAgentService } from "./plugin-managed-agents.js"; import { pluginManagedRoutineService } from "./plugin-managed-routines.js"; import { pluginManagedSkillService } from "./plugin-managed-skills.js"; import { assertConfiguredLocalFolder, assertWritableConfiguredLocalFolder, getStoredLocalFolders, deletePluginLocalFolderFile, inspectPluginLocalFolder, listPluginLocalFolderEntries, preparePluginLocalFolder, readPluginLocalFolderText, requireLocalFolderDeclaration, setStoredLocalFolder, writePluginLocalFolderTextAtomic, } from "./plugin-local-folders.js"; import { createPluginSecretsHandler } from "./plugin-secrets-handler.js"; import { logActivity } from "./activity-log.js"; import type { PluginEventBus } from "./plugin-event-bus.js"; import type { PluginWorkerManager } from "./plugin-worker-manager.js"; import { lookup as dnsLookup } from "node:dns/promises"; import type { IncomingMessage, RequestOptions as HttpRequestOptions } from "node:http"; import { request as httpRequest } from "node:http"; import { request as httpsRequest } from "node:https"; import { isIP } from "node:net"; import { logger } from "../middleware/logger.js"; import { getTelemetryClient } from "../telemetry.js"; import { accessService } from "./access.js"; import { authorizationService, type AuthorizationActor } from "./authorization.js"; import { redactEventPayload, sanitizeRecord } from "../redaction.js"; import type { WorkerHostCallContext } from "@paperclipai/plugin-sdk"; import { normalizeProviderFamily, SANDBOX_STARTUP_SPAN_ATTRS, } from "@paperclipai/adapter-utils/acpx-engine/startup-timing"; import { recordProviderPluginSpan, type ParsedTraceparent } from "../instrumentation.js"; // --------------------------------------------------------------------------- // SSRF protection for plugin HTTP fetch // --------------------------------------------------------------------------- /** Maximum time (ms) a plugin fetch request may take before being aborted. */ const PLUGIN_FETCH_TIMEOUT_MS = 30_000; /** Maximum time (ms) to wait for a DNS lookup before aborting. */ const DNS_LOOKUP_TIMEOUT_MS = 5_000; /** Only these protocols are allowed for plugin HTTP requests. */ const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]); const TELEMETRY_EVENT_NAME_REGEX = /^[a-z0-9][a-z0-9_-]*$/; /** * Check if an IP address is in a private/reserved range (RFC 1918, loopback, * link-local, etc.) that plugins should never be able to reach. * * Handles IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1) which Node's * dns.lookup may return depending on OS configuration. */ function isPrivateIP(ip: string): boolean { const lower = ip.toLowerCase(); // Unwrap IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) and re-check as IPv4 const v4MappedMatch = lower.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); if (v4MappedMatch && v4MappedMatch[1]) return isPrivateIP(v4MappedMatch[1]); // IPv4 patterns if (ip.startsWith("10.")) return true; if (ip.startsWith("172.")) { const second = parseInt(ip.split(".")[1]!, 10); if (second >= 16 && second <= 31) return true; } if (ip.startsWith("192.168.")) return true; if (ip.startsWith("127.")) return true; // loopback if (ip.startsWith("169.254.")) return true; // link-local if (ip === "0.0.0.0") return true; // IPv6 patterns if (lower === "::1") return true; // loopback if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // ULA if (lower.startsWith("fe80")) return true; // link-local if (lower === "::") return true; return false; } /** * Validate a URL for plugin fetch: protocol whitelist + private IP blocking. * * SSRF Prevention Strategy: * 1. Parse and validate the URL syntax * 2. Enforce protocol whitelist (http/https only) * 3. Resolve the hostname to IP(s) via DNS * 4. Validate that ALL resolved IPs are non-private * 5. Pin the first safe IP into the URL so fetch() does not re-resolve DNS * * This prevents DNS rebinding attacks where an attacker controls DNS to * resolve to a safe IP during validation, then to a private IP when fetch() runs. * * @returns Request-routing metadata used to connect directly to the resolved IP * while preserving the original hostname for HTTP Host and TLS SNI. */ interface ValidatedFetchTarget { parsedUrl: URL; resolvedAddress: string; hostHeader: string; tlsServername?: string; useTls: boolean; } async function validateAndResolveFetchUrl(urlString: string): Promise { let parsed: URL; try { parsed = new URL(urlString); } catch { throw new Error(`Invalid URL: ${urlString}`); } if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) { throw new Error( `Disallowed protocol "${parsed.protocol}" — only http: and https: are permitted`, ); } // Resolve the hostname to an IP and check for private ranges. // We pin the resolved IP into the URL to eliminate the TOCTOU window // between DNS resolution here and the second resolution fetch() would do. const originalHostname = parsed.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets const hostHeader = parsed.host; // includes port if non-default // Race the DNS lookup against a timeout to prevent indefinite hangs // when DNS is misconfigured or unresponsive. const dnsPromise = dnsLookup(originalHostname, { all: true }); const timeoutPromise = new Promise((_, reject) => { setTimeout( () => reject(new Error(`DNS lookup timed out after ${DNS_LOOKUP_TIMEOUT_MS}ms for ${originalHostname}`)), DNS_LOOKUP_TIMEOUT_MS, ); }); try { const results = await Promise.race([dnsPromise, timeoutPromise]); if (results.length === 0) { throw new Error(`DNS resolution returned no results for ${originalHostname}`); } // Filter to only non-private IPs instead of rejecting the entire request // when some IPs are private. This handles multi-homed hosts that resolve // to both private and public addresses. const safeResults = results.filter((entry) => !isPrivateIP(entry.address)); if (safeResults.length === 0) { throw new Error( `All resolved IPs for ${originalHostname} are in private/reserved ranges`, ); } const resolved = safeResults[0]!; return { parsedUrl: parsed, resolvedAddress: resolved.address, hostHeader, tlsServername: parsed.protocol === "https:" && isIP(originalHostname) === 0 ? originalHostname : undefined, useTls: parsed.protocol === "https:", }; } catch (err) { // Re-throw our own errors; wrap DNS failures if (err instanceof Error && ( err.message.startsWith("All resolved IPs") || err.message.startsWith("DNS resolution returned") || err.message.startsWith("DNS lookup timed out") )) throw err; throw new Error(`DNS resolution failed for ${originalHostname}: ${(err as Error).message}`); } } function buildPinnedRequestOptions( target: ValidatedFetchTarget, init?: RequestInit, ): { options: HttpRequestOptions & { servername?: string }; body: string | undefined } { const headers = new Headers(init?.headers); const method = init?.method ?? "GET"; const body = init?.body === undefined || init?.body === null ? undefined : typeof init.body === "string" ? init.body : String(init.body); headers.set("Host", target.hostHeader); if (body !== undefined && !headers.has("content-length") && !headers.has("transfer-encoding")) { headers.set("content-length", String(Buffer.byteLength(body))); } const pathname = `${target.parsedUrl.pathname}${target.parsedUrl.search}`; const auth = target.parsedUrl.username || target.parsedUrl.password ? `${decodeURIComponent(target.parsedUrl.username)}:${decodeURIComponent(target.parsedUrl.password)}` : undefined; return { options: { protocol: target.parsedUrl.protocol, host: target.resolvedAddress, port: target.parsedUrl.port ? Number(target.parsedUrl.port) : target.useTls ? 443 : 80, path: pathname, method, headers: Object.fromEntries(headers.entries()), auth, servername: target.tlsServername, }, body, }; } async function executePinnedHttpRequest( target: ValidatedFetchTarget, init: RequestInit | undefined, signal: AbortSignal, ): Promise<{ status: number; statusText: string; headers: Record; body: string }> { const { options, body } = buildPinnedRequestOptions(target, init); const response = await new Promise((resolve, reject) => { const requestFn = target.useTls ? httpsRequest : httpRequest; const req = requestFn({ ...options, signal }, resolve); req.on("error", reject); if (body !== undefined) { req.write(body); } req.end(); }); const MAX_RESPONSE_BODY_BYTES = 200 * 1024 * 1024; // 200 MB const chunks: Buffer[] = []; let totalBytes = 0; await new Promise((resolve, reject) => { response.on("data", (chunk: Buffer | string) => { const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); totalBytes += buf.length; if (totalBytes > MAX_RESPONSE_BODY_BYTES) { chunks.length = 0; response.destroy(new Error(`Response body exceeded ${MAX_RESPONSE_BODY_BYTES} bytes`)); return; } chunks.push(buf); }); response.on("end", resolve); response.on("error", reject); }); const headers: Record = {}; for (const [key, value] of Object.entries(response.headers)) { if (Array.isArray(value)) { headers[key] = value.join(", "); } else if (value !== undefined) { headers[key] = value; } } return { status: response.statusCode ?? 500, statusText: response.statusMessage ?? "", headers, body: Buffer.concat(chunks).toString("utf8"), }; } const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const PATH_LIKE_PATTERN = /[\\/]/; const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; function looksLikePath(value: string): boolean { const normalized = value.trim(); return ( PATH_LIKE_PATTERN.test(normalized) || WINDOWS_DRIVE_PATH_PATTERN.test(normalized) ) && !UUID_PATTERN.test(normalized); } function sanitizeWorkspaceText(value: string): string { const trimmed = value.trim(); if (!trimmed || UUID_PATTERN.test(trimmed)) return ""; return trimmed; } function sanitizeWorkspacePath(cwd: string | null): string { if (!cwd) return ""; return looksLikePath(cwd) ? cwd.trim() : ""; } function sanitizeWorkspaceName(name: string, fallbackPath: string): string { const safeName = sanitizeWorkspaceText(name); if (safeName && !looksLikePath(safeName)) { return safeName; } const normalized = fallbackPath.trim().replace(/[\\/]+$/, ""); const segments = normalized.split(/[\\/]/).filter(Boolean); return segments[segments.length - 1] ?? "Workspace"; } // --------------------------------------------------------------------------- // Buffered plugin log writes // --------------------------------------------------------------------------- /** How many buffered log entries trigger an immediate flush. */ const LOG_BUFFER_FLUSH_SIZE = 100; /** How often (ms) the buffer is flushed regardless of size. */ const LOG_BUFFER_FLUSH_INTERVAL_MS = 5_000; /** Max length for a single plugin log message (bytes/chars). */ const MAX_LOG_MESSAGE_LENGTH = 10_000; /** Max serialised JSON size for plugin log meta objects. */ const MAX_LOG_META_JSON_LENGTH = 50_000; /** Max length for a metric name. */ const MAX_METRIC_NAME_LENGTH = 500; /** Pino reserved field names that plugins must not overwrite. */ const PINO_RESERVED_KEYS = new Set([ "level", "time", "pid", "hostname", "msg", "v", ]); /** Truncate a string to `max` characters, appending a marker if truncated. */ function truncStr(s: string, max: number): string { if (s.length <= max) return s; return s.slice(0, max) + "...[truncated]"; } /** Sanitise a plugin-supplied meta object: enforce size limit and strip reserved keys. */ function sanitiseMeta(meta: Record | null | undefined): Record | null { if (meta == null) return null; // Strip pino reserved keys const cleaned: Record = {}; for (const [k, v] of Object.entries(meta)) { if (!PINO_RESERVED_KEYS.has(k)) { cleaned[k] = v; } } // Enforce total serialised size let json: string; try { json = JSON.stringify(cleaned); } catch { return { _sanitised: true, _error: "meta was not JSON-serialisable" }; } if (json.length > MAX_LOG_META_JSON_LENGTH) { return { _sanitised: true, _error: `meta exceeded ${MAX_LOG_META_JSON_LENGTH} chars` }; } return cleaned; } interface BufferedLogEntry { db: Db; pluginId: string; /** * Owning tenant for `plugin_logs.company_id` — populated when the caller * attributes the log/metric to a specific company so the row participates * in the `ON DELETE CASCADE` from `companies`. `null` means instance-scope * (cron jobs / public webhooks without a tenant); those rows survive * company deletes but are still attributable. */ companyId: string | null; level: string; message: string; meta: Record | null; } const _logBuffer: BufferedLogEntry[] = []; /** * Flush all buffered log entries to the database in a single batch insert per * unique db instance. Errors are swallowed with a console.error fallback so * flushing never crashes the process. */ export async function flushPluginLogBuffer(): Promise { if (_logBuffer.length === 0) return; // Drain the buffer atomically so concurrent flushes don't double-insert. const entries = _logBuffer.splice(0, _logBuffer.length); // Group entries by db identity so multi-db scenarios are handled correctly. const byDb = new Map(); for (const entry of entries) { const group = byDb.get(entry.db); if (group) { group.push(entry); } else { byDb.set(entry.db, [entry]); } } for (const [dbInstance, group] of byDb) { const values = group.map((e) => ({ pluginId: e.pluginId, companyId: e.companyId, level: e.level, message: e.message, meta: e.meta, })); try { await dbInstance.insert(pluginLogs).values(values); } catch (err) { try { logger.warn({ err, count: values.length }, "Failed to batch-persist plugin logs to DB"); } catch { console.error("[plugin-host-services] Batch log flush failed:", err); } } } } /** Interval handle for the periodic log flush. */ const _logFlushInterval = setInterval(() => { flushPluginLogBuffer().catch((err) => { console.error("[plugin-host-services] Periodic log flush error:", err); }); }, LOG_BUFFER_FLUSH_INTERVAL_MS); // Allow the interval to be unref'd so it doesn't keep the process alive in tests. if (_logFlushInterval.unref) _logFlushInterval.unref(); /** * buildHostServices — creates a concrete implementation of the `HostServices` * interface for a specific plugin. * * This implementation delegates to the core Paperclip domain services, * providing the bridge between the plugin worker's SDK and the host platform. * * @param db - Database connection instance. * @param pluginId - The UUID of the plugin installation record. * @param pluginKey - The unique identifier from the plugin manifest (e.g., "acme.linear"). * @param eventBus - The system-wide event bus for publishing plugin events. * @returns An object implementing the HostServices interface for the plugin SDK. */ /** Maximum time (ms) to keep a session event subscription alive before forcing cleanup. */ const SESSION_EVENT_SUBSCRIPTION_TIMEOUT_MS = 30 * 60 * 1_000; // 30 minutes // --------------------------------------------------------------------------- // Provider span trust boundary (the `span.record` host handler) // --------------------------------------------------------------------------- // // The plugin worker runs in a separate process, so the host treats every field // of a worker-sent span as untrusted input. The host re-clamps the span name // and every attribute here, before it records the span. A worker-side or // plugin-side helper is not sufficient; this is the single boundary. const SPAN_ATTRS = SANDBOX_STARTUP_SPAN_ATTRS; /** The closed set of provider span leaf names a plugin may emit. `pack` and * `transfer` are the host-local build and the byte upload. `ensureDirectory`, * `checkSymlinkEscape`, `promote`, `extractTarball`, and `postUploadCommand` * are the per-round-trip command spans in the inbound sync path. `session.open` * and `session.close` are the short spans that wrap a persistent-session create * and delete. */ const KNOWN_PROVIDER_SPAN_NAMES: ReadonlySet = new Set([ "pack", "transfer", "ensureDirectory", "checkSymlinkEscape", "promote", "extractTarball", "postUploadCommand", "session.open", "session.close", ]); /** Clamp the span name to a closed, namespaced set. A known name maps to * `sandbox.daytona.`; any other value maps to `sandbox.daytona.other`, so * a span name never carries free-form data. Only the daytona provider emits * these spans today, so the segment is the literal `daytona`. When a second * provider emits provider spans, derive the segment from the normalized * `provider` family attribute on the span instead of this literal. */ function clampProviderSpanName(raw: unknown): string { const name = typeof raw === "string" && KNOWN_PROVIDER_SPAN_NAMES.has(raw) ? raw : "other"; return `sandbox.daytona.${name}`; } /** The closed allowlist of attribute keys a provider span may carry. The host * drops every other key, so a command, an argument, a path, an id, a standard * output, a standard error, or an `extra` field can never ride a provider span. */ const PROVIDER_SPAN_ATTR_ALLOWLIST: ReadonlySet = new Set([ SPAN_ATTRS.provider, SPAN_ATTRS.outcome, SPAN_ATTRS.packWallMs, SPAN_ATTRS.transferWallMs, SPAN_ATTRS.transferGuardCount, SPAN_ATTRS.transferDirection, ]); /** The subset of allowed keys that carry a finite number. */ const PROVIDER_SPAN_NUMERIC_ATTRS: ReadonlySet = new Set([ SPAN_ATTRS.packWallMs, SPAN_ATTRS.transferWallMs, SPAN_ATTRS.transferGuardCount, ]); /** The closed value set for the `outcome` attribute. */ const KNOWN_SPAN_OUTCOMES: ReadonlySet = new Set(["ok", "skipped", "failed"]); /** The closed value set for the `transfer.direction` attribute. */ const KNOWN_TRANSFER_DIRECTIONS: ReadonlySet = new Set(["inbound", "outbound"]); /** * Re-clamp the worker-sent attributes at the trust boundary. Drop every key that * is not on the allowlist. Re-map `provider` through `normalizeProviderFamily`, * bound `outcome` and `transfer.direction` each to its closed set, and keep a * numeric attribute only when it is a finite number. The result holds only * bounded, low-cardinality values. */ export function clampProviderSpanAttributes( raw: Record | undefined, ): Record { const clamped: Record = {}; if (!raw) return clamped; for (const [key, value] of Object.entries(raw)) { if (!PROVIDER_SPAN_ATTR_ALLOWLIST.has(key)) continue; if (key === SPAN_ATTRS.provider) { clamped[key] = normalizeProviderFamily(typeof value === "string" ? value : undefined); continue; } if (key === SPAN_ATTRS.outcome) { if (typeof value === "string" && KNOWN_SPAN_OUTCOMES.has(value)) clamped[key] = value; continue; } if (key === SPAN_ATTRS.transferDirection) { if (typeof value === "string" && KNOWN_TRANSFER_DIRECTIONS.has(value)) clamped[key] = value; continue; } if (PROVIDER_SPAN_NUMERIC_ATTRS.has(key)) { if (typeof value === "number" && Number.isFinite(value)) clamped[key] = value; continue; } } return clamped; } /** * Parse and validate a W3C `traceparent`. Return the parts, or `null` when the * value is absent or malformed. The host mints the value, but this is the trust * boundary, so it validates before use. It never logs the value. */ export function parseTraceparent(raw: string | undefined | null): ParsedTraceparent | null { if (typeof raw !== "string") return null; const match = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/.exec(raw); if (!match) return null; const [, version, traceId, spanId, flags] = match; if (version === "ff") return null; // the W3C spec forbids version 0xff if (traceId === "0".repeat(32)) return null; // an all-zero trace id is invalid if (spanId === "0".repeat(16)) return null; // an all-zero span id is invalid return { traceId, spanId, traceFlags: parseInt(flags, 16) }; } /** Keep only the numeric status code. A status message could carry free-form * text, so the host drops it — never a standard-stream text on a span. */ function clampSpanStatus( status: { code?: unknown; message?: unknown } | undefined, ): { code: number } | undefined { if (!status || typeof status.code !== "number" || !Number.isFinite(status.code)) return undefined; return { code: status.code }; } /** The largest span duration the host accepts as a real wall-clock width. A * larger difference means a skewed or wrong clock, so the host drops the pair. */ const MAX_PROVIDER_SPAN_DURATION_MS = 10 * 60 * 1000; // 10 minutes /** The largest age the host accepts for a span start time relative to its own * clock. An older start means a stale or wrong clock, so the host drops the * pair. A small negative skew (a start slightly ahead of the host clock) is * allowed, because the host and the worker clocks can differ. */ const MAX_PROVIDER_SPAN_START_AGE_MS = 60 * 60 * 1000; // 1 hour /** The largest amount by which the end time may be ahead of the host clock. A * larger lead means a wrong or skewed clock, so the host drops the pair. This * upper bound rejects a timestamp pair that is far in the future. It still * allows a small clock skew between the host and the worker. */ const MAX_PROVIDER_SPAN_END_SKEW_MS = 60 * 1000; // 1 minute /** * Validate the worker-sent start-time and end-time pair at the trust boundary. * Return the pair only when it passes the clock-safety policy: * - both values are finite numbers; * - the start time is less than or equal to the end time; * - the duration is not larger than a bounded ceiling; * - the start time is not older than a bounded age relative to the host clock; * - the end time is not ahead of the host clock by more than a bounded skew. * Return `undefined` when any check fails, so the host falls back to the * synchronous open-and-end path. */ function validateProviderSpanTimes( startTimeMs: unknown, endTimeMs: unknown, ): { startTimeMs: number; endTimeMs: number } | undefined { if (typeof startTimeMs !== "number" || !Number.isFinite(startTimeMs)) return undefined; if (typeof endTimeMs !== "number" || !Number.isFinite(endTimeMs)) return undefined; if (startTimeMs > endTimeMs) return undefined; if (endTimeMs - startTimeMs > MAX_PROVIDER_SPAN_DURATION_MS) return undefined; if (Date.now() - startTimeMs > MAX_PROVIDER_SPAN_START_AGE_MS) return undefined; if (endTimeMs - Date.now() > MAX_PROVIDER_SPAN_END_SKEW_MS) return undefined; return { startTimeMs, endTimeMs }; } /** * Record a worker-sent provider span through the real tracer. This is the host * trust boundary: it validates the host-minted `traceparent`, re-clamps the span * name and every attribute, mints the parentage host-side, and drops a status * message. It validates the optional start-time and end-time pair with a * clock-safety policy; a valid pair gives the span its true native width, and an * absent or invalid pair falls back to the synchronous open-and-end path. It * rejects a span with a missing or malformed `traceparent`. It never throws — * observability must not change control flow. */ export function recordWorkerProviderSpan( params: { name: string; attributes?: Record; status?: { code?: unknown; message?: unknown }; startTimeMs?: unknown; endTimeMs?: unknown; }, context: WorkerHostCallContext | undefined, ): void { const parent = parseTraceparent(context?.traceparent); if (!parent) return; // reject a missing or malformed traceparent const times = validateProviderSpanTimes(params.startTimeMs, params.endTimeMs); const status = clampSpanStatus(params.status); recordProviderPluginSpan({ name: clampProviderSpanName(params.name), parent, attributes: clampProviderSpanAttributes(params.attributes), ...(status ? { status } : {}), ...(times ? { startTimeMs: times.startTimeMs, endTimeMs: times.endTimeMs } : {}), }); } export function buildHostServices( db: Db, pluginId: string, pluginKey: string, eventBus: PluginEventBus, notifyWorker?: (method: string, params: unknown) => void, options: { pluginWorkerManager?: PluginWorkerManager; manifest?: import("@paperclipai/shared").PaperclipPluginManifestV1; heartbeatRuntimeEnv?: Record; } = {}, ): HostServices & { dispose(): void } { const registry = pluginRegistryService(db); const stateStore = pluginStateStore(db); const pluginDb = pluginDatabaseService(db); const secretsHandler = createPluginSecretsHandler({ db, pluginId }); const companies = companyService(db); const agents = agentService(db); const managedAgents = pluginManagedAgentService(db, { pluginId, pluginKey, manifest: options.manifest, instructionTemplateVariables: async (companyId) => { const variables: Record = {}; for (const declaration of options.manifest?.localFolders ?? []) { const status = await inspectPluginLocalFolder({ folderKey: declaration.folderKey, declaration, storedConfig: await getStoredLocalFolderConfig(companyId, declaration.folderKey), }); const prefix = `localFolders.${declaration.folderKey}`; variables[`${prefix}.path`] = status.realPath ?? status.path ?? null; variables[`${prefix}.agentsPath`] = status.realPath ? path.join(status.realPath, "AGENTS.md") : null; } return variables; }, }); const managedRoutines = pluginManagedRoutineService(db, { pluginId, pluginKey, manifest: options.manifest, pluginWorkerManager: options.pluginWorkerManager, }); const managedSkills = pluginManagedSkillService(db, { pluginId, pluginKey, manifest: options.manifest, }); const heartbeat = heartbeatService(db, { pluginWorkerManager: options.pluginWorkerManager, runtimeEnv: options.heartbeatRuntimeEnv, }); const projects = projectService(db); const executionWorkspaces = executionWorkspaceService(db); const issues = issueService(db); const documents = documentService(db); const goals = goalService(db); const access = accessService(db); const authorization = authorizationService(db); const budgets = budgetService(db); const issueApprovals = issueApprovalService(db); const approvalSvc = approvalService(db); const interactions = issueThreadInteractionService(db); const scopedBus = eventBus.forPlugin(pluginKey); // Track active session event subscriptions for cleanup const activeSubscriptions = new Set<{ unsubscribe: () => void; timer: ReturnType }>(); let disposed = false; const ensureCompanyId = (companyId?: string) => { if (!companyId) throw new Error("companyId is required for this operation"); return companyId; }; const parseWindowValue = (value: unknown): number | null => { if (typeof value === "number" && Number.isFinite(value)) { return Math.max(0, Math.floor(value)); } if (typeof value === "string" && value.trim().length > 0) { const parsed = Number(value); if (Number.isFinite(parsed)) { return Math.max(0, Math.floor(parsed)); } } return null; }; const applyWindow = (rows: T[], params?: { limit?: unknown; offset?: unknown }): T[] => { const offset = parseWindowValue(params?.offset) ?? 0; const limit = parseWindowValue(params?.limit); if (limit == null) return rows.slice(offset); return rows.slice(offset, offset + limit); }; const authorizationAuditDecisionCondition = (decisionFilter: string) => { const conditions = [ sql`lower(${activityLog.details}->>'decision') = ${decisionFilter}`, decisionFilter === "allow" ? sql`left(coalesce(${activityLog.details}->>'reason', ''), 6) = 'allow_'` : undefined, decisionFilter === "deny" ? sql`left(coalesce(${activityLog.details}->>'reason', ''), 5) = 'deny_'` : undefined, decisionFilter === "allow" ? sql`${activityLog.details}->>'allowed' = 'true'` : undefined, decisionFilter === "deny" ? sql`${activityLog.details}->>'allowed' = 'false'` : undefined, ].filter((condition): condition is NonNullable => Boolean(condition)); return sql`(${sql.join(conditions, sql` OR `)})`; }; /** * Plugins are instance-wide in the current runtime. Company IDs are still * required for company-scoped data access, but there is no per-company * availability gate to enforce here. */ const ensurePluginAvailableForCompany = async (_companyId: string) => {}; const getLocalFolderDeclaration = (folderKey: string) => requireLocalFolderDeclaration(options.manifest?.localFolders, folderKey); const getStoredLocalFolderConfig = async (companyId: string, folderKey: string) => { ensureCompanyId(companyId); await ensurePluginAvailableForCompany(companyId); const settings = await registry.getCompanySettings(pluginId, companyId); return getStoredLocalFolders(settings?.settingsJson)[folderKey] ?? null; }; const inspectStoredLocalFolder = async (companyId: string, folderKey: string) => inspectPluginLocalFolder({ folderKey, declaration: getLocalFolderDeclaration(folderKey), storedConfig: await getStoredLocalFolderConfig(companyId, folderKey), }); const inCompany = ( record: T | null | undefined, companyId: string, ): record is T => Boolean(record && record.companyId === companyId); const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); const readProviderMetadata = (metadata: Record | null | undefined) => { if (!isRecord(metadata)) return null; if (isRecord(metadata.providerMetadata)) return { ...metadata.providerMetadata }; const rebuild = metadata.rebuild; if (!isRecord(rebuild)) return null; const rebuildMetadata = rebuild.metadata; if (!isRecord(rebuildMetadata) || !isRecord(rebuildMetadata.providerMetadata)) return null; return { ...rebuildMetadata.providerMetadata }; }; const toPluginExecutionWorkspaceMetadata = ( workspace: NonNullable>>, ): PluginExecutionWorkspaceMetadata => ({ id: workspace.id, companyId: workspace.companyId, projectId: workspace.projectId, projectWorkspaceId: workspace.projectWorkspaceId, path: workspace.cwd ?? workspace.providerRef, cwd: workspace.cwd, repoUrl: workspace.repoUrl, baseRef: workspace.baseRef, branchName: workspace.branchName, providerType: workspace.providerType, providerMetadata: readProviderMetadata(workspace.metadata), }); const requireInCompany = ( entityName: string, record: T | null | undefined, companyId: string, ): T => { if (!inCompany(record, companyId)) { throw new Error(`${entityName} not found`); } return record; }; /** * Verify `userId` is an active human member of `companyId` before letting a * plugin attribute a mutation to them. Mirrors the authorization bar the * web app's own board routes apply — a plugin can only ever attribute an * action to an identity that could have taken it in the web app itself. * Used by any plugin capability that accepts an `actorUserId` * (`createComment`'s human-attributed path, `respondInteraction`, and * `approvals.decide`). * * All current call sites are non-safe (write) actions, so by default this * also rejects a `viewer`-role member — the web app's board write-routes * treat `membershipRole === "viewer"` as read-only and 403 it ("Viewer * access is read-only", routes/authz.ts:115-116). Without this bar a plugin * holding `approvals.respond` / `issue.interactions.respond` could attribute * a decision to a viewer who is denied that same action in the web UI — * strictly more authority than the paired user has (privilege escalation). * A future *read-only* attribution path can opt into allowing viewers with * `{ allowViewer: true }`; the default is failure-closed. */ const requireActiveHumanMember = async ( companyId: string, userId: string, { allowViewer = false }: { allowViewer?: boolean } = {}, ): Promise => { const [membership] = await db .select({ id: companyMemberships.id, membershipRole: companyMemberships.membershipRole }) .from(companyMemberships) .where(and( eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId), eq(companyMemberships.status, "active"), )) .limit(1); if (!membership) { throw new Error(`actorUserId "${userId}" is not an active human member of this company`); } if (!allowViewer && membership.membershipRole === "viewer") { throw new Error(`actorUserId "${userId}" has viewer (read-only) access and cannot take this write action`); } }; /** * Wake the assignee of a continuation issue after a plugin-relayed board-user * interaction resolution, mirroring the web app's board interaction routes * (routes/issues.ts's queueResolvedInteractionContinuationWakeup) so a * confirmation accepted/rejected from chat resumes the agent the same way it * would from the web app. Deliberately narrower than the HTTP helper: it * carries the core interaction context plus the plan-review continuation * payload (the confirmation kind the gateway resolves), and skips the * checkbox/tool-action/item-verdict extras that the gateway's yes/no decision * cards never produce. Failure-tolerant: a wake failure is logged, never * thrown back to the plugin (the decision itself already applied). */ const queuePluginInteractionContinuationWakeup = (args: { issue: { id: string; assigneeAgentId: string | null; status: string }; interaction: { id: string; kind: string; status: string; continuationPolicy: string; sourceCommentId?: string | null; sourceRunId?: string | null; payload?: unknown; }; actorUserId: string; source: string; }): void => { const { interaction, issue } = args; if ( interaction.continuationPolicy !== "wake_assignee" && interaction.continuationPolicy !== "wake_assignee_on_accept" ) return; if ( interaction.continuationPolicy === "wake_assignee_on_accept" && interaction.status !== "accepted" ) return; if (interaction.status === "expired") return; if (!issue.assigneeAgentId || issue.status === "done" || issue.status === "cancelled") return; let planReviewInteraction: Record | null = null; if (interaction.kind === "request_confirmation" && isRecord(interaction.payload)) { const target = isRecord(interaction.payload.target) ? interaction.payload.target : null; if ( target && target.type === "issue_document" && target.key === "plan" && typeof target.issueId === "string" && target.issueId === issue.id ) { planReviewInteraction = { id: interaction.id, kind: interaction.kind, status: interaction.status, target, acceptedTargetRevision: interaction.status === "accepted" ? target : null, }; } } void heartbeat.wakeup(issue.assigneeAgentId, { source: "automation", triggerDetail: "system", reason: "issue_commented", payload: { issueId: issue.id, interactionId: interaction.id, interactionKind: interaction.kind, interactionStatus: interaction.status, sourceCommentId: interaction.sourceCommentId ?? null, sourceRunId: interaction.sourceRunId ?? null, ...(planReviewInteraction ? { planReviewInteraction } : {}), mutation: "interaction", }, requestedByActorType: "user", requestedByActorId: args.actorUserId, contextSnapshot: { issueId: issue.id, taskId: issue.id, interactionId: interaction.id, interactionKind: interaction.kind, interactionStatus: interaction.status, ...(planReviewInteraction ? { planReviewInteraction } : {}), wakeReason: "issue_commented", source: `plugin:${pluginKey}`, }, }).catch((err) => logger.warn({ err, issueId: issue.id, interactionId: interaction.id, agentId: issue.assigneeAgentId, source: args.source, }, "failed to wake assignee on plugin-relayed interaction resolution")); }; /** * Redact an approval's payload before returning it through the plugin bridge, * mirroring the web app's own approval read routes (routes/approvals.ts's * redactApprovalPayload). Ensures the chat surface never receives secrets the * web app itself hides from an approval reader. */ const redactApprovalPayload = }>(approval: T): T => ({ ...approval, payload: redactEventPayload(approval.payload) ?? {}, }); const pluginActivityDetails = ( details: Record | null | undefined, actor?: { actorAgentId?: string | null; actorUserId?: string | null; actorRunId?: string | null }, ) => { const initiatingActorType = actor?.actorAgentId ? "agent" : actor?.actorUserId ? "user" : null; const initiatingActorId = actor?.actorAgentId ?? actor?.actorUserId ?? null; return { ...(details ?? {}), sourcePluginId: pluginId, sourcePluginKey: pluginKey, initiatingActorType, initiatingActorId, initiatingAgentId: actor?.actorAgentId ?? null, initiatingUserId: actor?.actorUserId ?? null, initiatingRunId: actor?.actorRunId ?? null, pluginId, pluginKey, }; }; const defaultPluginOriginKind = `plugin:${pluginKey}`; const normalizePluginOriginKind = (originKind: unknown = defaultPluginOriginKind) => { if (originKind == null || originKind === "") return defaultPluginOriginKind; if (typeof originKind !== "string") { throw new Error("Plugin issue originKind must be a string"); } if (originKind === defaultPluginOriginKind || originKind.startsWith(`${defaultPluginOriginKind}:`)) { return originKind; } throw new Error(`Plugin may only use originKind values under ${defaultPluginOriginKind}`); }; const assertReadableOriginFilter = (originKind: unknown) => { if (typeof originKind !== "string" || !originKind.startsWith("plugin:")) return; normalizePluginOriginKind(originKind); }; const logPluginActivity = async (input: { companyId: string; action: string; entityType: string; entityId: string; details?: Record | null; actor?: { actorAgentId?: string | null; actorUserId?: string | null; actorRunId?: string | null }; }) => { await logActivity(db, { companyId: input.companyId, actorType: "plugin", actorId: pluginId, agentId: input.actor?.actorAgentId ?? null, runId: input.actor?.actorRunId ?? null, action: input.action, entityType: input.entityType, entityId: input.entityId, details: pluginActivityDetails(input.details, input.actor), }); }; const collectIssueSubtreeIds = async (companyId: string, rootIssueId: string) => { const seen = new Set([rootIssueId]); let frontier = [rootIssueId]; while (frontier.length > 0) { const children = await db .select({ id: issuesTable.id }) .from(issuesTable) .where(and(eq(issuesTable.companyId, companyId), inArray(issuesTable.parentId, frontier))); frontier = children.map((child) => child.id).filter((id) => !seen.has(id)); for (const id of frontier) seen.add(id); } return [...seen]; }; const getIssueRunSummaries = async ( companyId: string, issueIds: string[], options: { activeOnly?: boolean } = {}, ) => { if (issueIds.length === 0) return []; const issueIdExpr = sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`; const statusCondition = options.activeOnly ? inArray(heartbeatRuns.status, ["queued", "running"]) : undefined; const rows = await db .select({ id: heartbeatRuns.id, issueId: issueIdExpr, agentId: heartbeatRuns.agentId, status: heartbeatRuns.status, invocationSource: heartbeatRuns.invocationSource, triggerDetail: heartbeatRuns.triggerDetail, startedAt: heartbeatRuns.startedAt, finishedAt: heartbeatRuns.finishedAt, error: heartbeatRuns.error, createdAt: heartbeatRuns.createdAt, }) .from(heartbeatRuns) .where(and(eq(heartbeatRuns.companyId, companyId), inArray(issueIdExpr, issueIds), statusCondition)) .orderBy(desc(heartbeatRuns.createdAt)) .limit(100); return rows.map((row) => ({ ...row, startedAt: row.startedAt?.toISOString() ?? null, finishedAt: row.finishedAt?.toISOString() ?? null, createdAt: row.createdAt.toISOString(), })); }; const setBlockedByWithActivity = async (params: { issueId: string; companyId: string; blockedByIssueIds: string[]; mutation: "set" | "add" | "remove"; actorAgentId?: string | null; actorUserId?: string | null; actorRunId?: string | null; }) => { const existing = requireInCompany("Issue", await issues.getById(params.issueId), params.companyId); const previous = await issues.getRelationSummaries(params.issueId); await issues.update(params.issueId, { blockedByIssueIds: params.blockedByIssueIds, actorAgentId: params.actorAgentId ?? null, actorUserId: params.actorUserId ?? null, } as any); const relations = await issues.getRelationSummaries(params.issueId); await logPluginActivity({ companyId: params.companyId, action: "issue.relations.updated", entityType: "issue", entityId: params.issueId, actor: { actorAgentId: params.actorAgentId, actorUserId: params.actorUserId, actorRunId: params.actorRunId, }, details: { identifier: existing.identifier, mutation: params.mutation, blockedByIssueIds: params.blockedByIssueIds, previousBlockedByIssueIds: previous.blockedBy.map((relation) => relation.id), }, }); return relations; }; const getIssueCostSummary = async ( companyId: string, issueIds: string[], billingCode?: string | null, ) => { const scopeConditions = [ issueIds.length > 0 ? inArray(costEvents.issueId, issueIds) : undefined, billingCode ? eq(costEvents.billingCode, billingCode) : undefined, ].filter((condition): condition is NonNullable => Boolean(condition)); if (scopeConditions.length === 0) { return { costCents: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, billingCode: billingCode ?? null, }; } const scopeCondition = scopeConditions.length === 1 ? scopeConditions[0]! : and(...scopeConditions); const [row] = await db .select({ costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::double precision`, cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::double precision`, outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::double precision`, }) .from(costEvents) .where(and(eq(costEvents.companyId, companyId), scopeCondition)); return { costCents: Number(row?.costCents ?? 0), inputTokens: Number(row?.inputTokens ?? 0), cachedInputTokens: Number(row?.cachedInputTokens ?? 0), outputTokens: Number(row?.outputTokens ?? 0), billingCode: billingCode ?? null, }; }; const getOpenBudgetIncidents = async (companyId: string) => { const rows = await db .select({ id: budgetIncidents.id, scopeType: budgetIncidents.scopeType, scopeId: budgetIncidents.scopeId, metric: budgetIncidents.metric, windowKind: budgetIncidents.windowKind, thresholdType: budgetIncidents.thresholdType, amountLimit: budgetIncidents.amountLimit, amountObserved: budgetIncidents.amountObserved, status: budgetIncidents.status, approvalId: budgetIncidents.approvalId, createdAt: budgetIncidents.createdAt, }) .from(budgetIncidents) .where(and(eq(budgetIncidents.companyId, companyId), eq(budgetIncidents.status, "open"))) .orderBy(desc(budgetIncidents.createdAt)); return rows.map((row) => ({ ...row, createdAt: row.createdAt.toISOString(), })); }; const INVITE_TOKEN_PREFIX = "pcp_invite_"; // 256 bits of entropy, base64url-encoded. Keep in sync with createInviteToken // in routes/access.ts. The token is public, so it must not be brute-forceable. const INVITE_TOKEN_ENTROPY_BYTES = 32; const INVITE_TOKEN_MAX_RETRIES = 5; const COMPANY_INVITE_TTL_MS = 72 * 60 * 60 * 1000; const hashToken = (token: string) => createHash("sha256").update(token).digest("hex"); const createInviteToken = () => { const suffix = randomBytes(INVITE_TOKEN_ENTROPY_BYTES).toString("base64url"); return `${INVITE_TOKEN_PREFIX}${suffix}`; }; const isInviteTokenHashCollisionError = (error: unknown) => { const candidates = [ error, (error as { cause?: unknown } | null)?.cause ?? null, ]; for (const candidate of candidates) { if (!candidate || typeof candidate !== "object") continue; const code = "code" in candidate && typeof candidate.code === "string" ? candidate.code : null; const message = "message" in candidate && typeof candidate.message === "string" ? candidate.message : ""; const constraint = "constraint" in candidate && typeof candidate.constraint === "string" ? candidate.constraint : null; if (code !== "23505") continue; if (constraint === "invites_token_hash_unique_idx") return true; if (message.includes("invites_token_hash_unique_idx")) return true; } return false; }; const inviteState = (invite: typeof invites.$inferSelect) => { if (invite.revokedAt) return "revoked" as const; if (invite.acceptedAt) return "accepted" as const; if (invite.expiresAt <= new Date()) return "expired" as const; return "active" as const; }; const redactInvite = (invite: typeof invites.$inferSelect) => { const { tokenHash: _tokenHash, defaultsPayload, ...safeInvite } = invite; return { ...safeInvite, allowedJoinTypes: safeInvite.allowedJoinTypes as InviteJoinType, defaultsPayload: defaultsPayload && typeof defaultsPayload === "object" ? sanitizeRecord(defaultsPayload) : defaultsPayload ?? null, state: inviteState(invite), }; }; const inviteStateWhereClause = (state: unknown) => { const now = new Date(); switch (state) { case "active": return and(isNull(invites.revokedAt), isNull(invites.acceptedAt), gt(invites.expiresAt, now)); case "accepted": return isNotNull(invites.acceptedAt); case "expired": return and(isNull(invites.revokedAt), isNull(invites.acceptedAt), lte(invites.expiresAt, now)); case "revoked": return isNotNull(invites.revokedAt); default: return undefined; } }; const mergeInviteDefaults = (defaultsPayload: Record | null | undefined, agentMessage: string | null, humanRole: string | null) => { const defaults = defaultsPayload && typeof defaultsPayload === "object" ? { ...defaultsPayload } : {}; if (humanRole) { defaults.human = { ...(typeof defaults.human === "object" && defaults.human !== null ? defaults.human as Record : {}), role: humanRole, }; } if (agentMessage) { defaults.agent = { ...(typeof defaults.agent === "object" && defaults.agent !== null ? defaults.agent as Record : {}), message: agentMessage, }; } return sanitizeRecord(defaults); }; const redactGrant = (grant: typeof principalPermissionGrants.$inferSelect) => ({ ...grant, principalType: grant.principalType as PrincipalType, permissionKey: grant.permissionKey as PermissionKey, scope: grant.scope && typeof grant.scope === "object" ? sanitizeRecord(grant.scope) : grant.scope ?? null, }); const loadPluginMember = async (companyId: string, memberId: string) => { const member = await access.getMemberById(companyId, memberId); if (!member) return null; const grants = await access.listPrincipalGrants( companyId, member.principalType as PrincipalType, member.principalId, ); return { ...member, principalType: member.principalType as PrincipalType, status: member.status as "pending" | "active" | "suspended" | "archived", grants: grants.map(redactGrant), }; }; const pluginAssignmentActor = (actor: { type: "agent" | "board"; agentId?: string | null; companyId?: string | null; userId?: string | null; companyIds?: string[]; }): AuthorizationActor => { if (actor.type === "agent") { return { type: "agent", agentId: actor.agentId ?? null, companyId: actor.companyId ?? null, source: "agent_key", }; } return { type: "board", userId: actor.userId ?? null, companyIds: Array.isArray(actor.companyIds) ? actor.companyIds : [], source: "session", }; }; const policyPathForResource = (resourceType: "company" | "agent" | "project" | "issue") => { switch (resourceType) { case "agent": return { table: "agent" as const }; case "project": return { table: "project" as const }; case "issue": return { table: "issue" as const }; case "company": return { table: "company" as const }; } }; const readAuthorizationPolicy = async (companyId: string, resourceType: "company" | "agent" | "project" | "issue", resourceId: string) => { const pathInfo = policyPathForResource(resourceType); if (pathInfo.table === "agent") { const agent = await agents.getById(resourceId); if (!inCompany(agent, companyId)) return null; const permissions = agent.permissions && typeof agent.permissions === "object" ? agent.permissions as Record : {}; return { resourceType, resourceId, companyId, policy: permissions.authorizationPolicy && typeof permissions.authorizationPolicy === "object" ? sanitizeRecord(permissions.authorizationPolicy as Record) : null, updatedAt: agent.updatedAt, }; } if (pathInfo.table === "project") { const project = await projects.getById(resourceId); if (!inCompany(project, companyId)) return null; const policy = project.executionWorkspacePolicy && typeof project.executionWorkspacePolicy === "object" ? (project.executionWorkspacePolicy as unknown as Record).authorizationPolicy : null; return { resourceType, resourceId, companyId, policy: policy && typeof policy === "object" ? sanitizeRecord(policy as Record) : null, updatedAt: project.updatedAt, }; } if (pathInfo.table === "issue") { const issue = await issues.getById(resourceId); if (!inCompany(issue, companyId)) return null; const policy = issue.executionPolicy && typeof issue.executionPolicy === "object" ? (issue.executionPolicy as Record).authorizationPolicy : null; return { resourceType, resourceId, companyId, policy: policy && typeof policy === "object" ? sanitizeRecord(policy as Record) : null, updatedAt: issue.updatedAt, }; } const company = await companies.getById(resourceId); if (!company || company.id !== companyId) return null; return { resourceType, resourceId, companyId, policy: null, updatedAt: company.updatedAt }; }; return { config: { async get(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const configRow = await registry.getConfig(pluginId, companyId); return configRow?.configJson ?? {}; }, }, localFolders: { async declarations() { return options.manifest?.localFolders ?? []; }, async configure(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const declaration = getLocalFolderDeclaration(params.folderKey); const existing = await registry.getCompanySettings(pluginId, companyId); const existingConfig = getStoredLocalFolders(existing?.settingsJson)[params.folderKey] ?? null; await preparePluginLocalFolder({ folderKey: params.folderKey, declaration, storedConfig: existingConfig, overrideConfig: { path: params.path, }, }); const status = await inspectPluginLocalFolder({ folderKey: params.folderKey, declaration, storedConfig: existingConfig, overrideConfig: { path: params.path, }, }); const nextSettings = setStoredLocalFolder(existing?.settingsJson, params.folderKey, { path: params.path, access: status.access, requiredDirectories: status.requiredDirectories, requiredFiles: status.requiredFiles, }); await registry.upsertCompanySettings(pluginId, companyId, { enabled: existing?.enabled ?? true, settingsJson: nextSettings, lastError: status.healthy ? null : status.problems.map((item: { message: string }) => item.message).join("; "), }); return status; }, async status(params) { return inspectStoredLocalFolder(params.companyId, params.folderKey); }, async list(params) { const status = await inspectStoredLocalFolder(params.companyId, params.folderKey); assertConfiguredLocalFolder(status); const listing = await listPluginLocalFolderEntries(status.realPath!, { relativePath: params.relativePath, recursive: params.recursive, maxEntries: params.maxEntries, }); return { ...listing, folderKey: params.folderKey }; }, async readText(params) { const status = await inspectStoredLocalFolder(params.companyId, params.folderKey); assertConfiguredLocalFolder(status); return readPluginLocalFolderText(status.realPath!, params.relativePath); }, async writeTextAtomic(params) { const companyId = ensureCompanyId(params.companyId); await preparePluginLocalFolder({ folderKey: params.folderKey, declaration: getLocalFolderDeclaration(params.folderKey), storedConfig: await getStoredLocalFolderConfig(companyId, params.folderKey), }); const status = await inspectStoredLocalFolder(companyId, params.folderKey); assertWritableConfiguredLocalFolder(status); await writePluginLocalFolderTextAtomic(status.realPath!, params.relativePath, params.contents); return inspectStoredLocalFolder(companyId, params.folderKey); }, async deleteFile(params) { const companyId = ensureCompanyId(params.companyId); const status = await inspectStoredLocalFolder(companyId, params.folderKey); assertWritableConfiguredLocalFolder(status); await deletePluginLocalFolderFile(status.realPath!, params.relativePath, params.folderKey); return inspectStoredLocalFolder(companyId, params.folderKey); }, }, state: { async get(params) { return stateStore.get(pluginId, params.scopeKind as any, params.stateKey, { scopeId: params.scopeId, namespace: params.namespace, }); }, async set(params) { await stateStore.set(pluginId, { scopeKind: params.scopeKind as any, scopeId: params.scopeId, namespace: params.namespace, stateKey: params.stateKey, value: params.value, }); }, async delete(params) { await stateStore.delete(pluginId, params.scopeKind as any, params.stateKey, { scopeId: params.scopeId, namespace: params.namespace, }); }, }, db: { async namespace() { return pluginDb.getRuntimeNamespace(pluginId); }, async query(params) { return pluginDb.query(pluginId, params.sql, params.params); }, async execute(params) { return pluginDb.execute(pluginId, params.sql, params.params); }, }, entities: { async upsert(params) { return registry.upsertEntity(pluginId, params as any) as any; }, async list(params) { return registry.listEntities(pluginId, params as any) as any; }, }, events: { async emit(params) { if (params.companyId) { await ensurePluginAvailableForCompany(params.companyId); } await scopedBus.emit(params.name, params.companyId, params.payload); }, async subscribe(params: { eventPattern: string; filter?: Record | null }) { const handler = async (event: import("@paperclipai/plugin-sdk").PluginEvent) => { if (notifyWorker) { notifyWorker("onEvent", { event }); } }; if (params.filter) { scopedBus.subscribe(params.eventPattern as any, params.filter as any, handler); } else { scopedBus.subscribe(params.eventPattern as any, handler); } }, }, http: { async fetch(params) { // SSRF protection: validate protocol whitelist + block private IPs. // Resolve once, then connect directly to that IP to prevent DNS rebinding. const target = await validateAndResolveFetchUrl(params.url); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), PLUGIN_FETCH_TIMEOUT_MS); try { const init = params.init as RequestInit | undefined; return await executePinnedHttpRequest(target, init, controller.signal); } finally { clearTimeout(timeout); } }, }, secrets: { async resolve(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); return secretsHandler.resolve({ ...params, companyId }); }, }, activity: { async log(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); await logActivity(db, { companyId, actorType: "plugin", actorId: pluginId, action: params.message, entityType: params.entityType ?? "plugin", entityId: params.entityId ?? pluginId, details: pluginActivityDetails(params.metadata), }); }, }, metrics: { async write(params) { const safeName = truncStr(String(params.name ?? ""), MAX_METRIC_NAME_LENGTH); logger.debug({ pluginId, name: safeName, value: params.value, tags: params.tags }, "Plugin metric write"); // Persist metrics to plugin_logs via the batch buffer (same path as // logger.log) so they benefit from batched writes and are flushed // reliably on shutdown. Using level "metric" makes them queryable // alongside regular logs via the same API (§26). _logBuffer.push({ db, pluginId, companyId: params.companyId ?? null, level: "metric", message: safeName, meta: sanitiseMeta({ value: params.value, tags: params.tags ?? null }), }); if (_logBuffer.length >= LOG_BUFFER_FLUSH_SIZE) { flushPluginLogBuffer().catch((err) => { console.error("[plugin-host-services] Triggered metric flush failed:", err); }); } }, }, telemetry: { async track(params) { const eventName = String(params.eventName ?? "").trim(); if (!TELEMETRY_EVENT_NAME_REGEX.test(eventName)) { throw new Error( 'Plugin telemetry event names must be lowercase slugs using letters, numbers, "_" or "-".', ); } const telemetryClient = getTelemetryClient(); if (!telemetryClient) return; telemetryClient.trackDynamic(`plugin.${pluginKey}.${eventName}`, params.dimensions); }, }, logger: { async log(params) { const { level, meta } = params; const safeMessage = truncStr(String(params.message ?? ""), MAX_LOG_MESSAGE_LENGTH); const safeMeta = sanitiseMeta(meta); const pluginLogger = logger.child({ service: "plugin-worker", pluginId }); const logFields = { ...safeMeta, pluginLogLevel: level, pluginTimestamp: new Date().toISOString(), }; if (level === "error") pluginLogger.error(logFields, `[plugin] ${safeMessage}`); else if (level === "warn") pluginLogger.warn(logFields, `[plugin] ${safeMessage}`); else if (level === "debug") pluginLogger.debug(logFields, `[plugin] ${safeMessage}`); else pluginLogger.info(logFields, `[plugin] ${safeMessage}`); // Persist to plugin_logs table via the module-level batch buffer (§26.1). // Fire-and-forget — logging should never block the worker. _logBuffer.push({ db, pluginId, companyId: params.companyId ?? null, level: level ?? "info", message: safeMessage, meta: safeMeta, }); if (_logBuffer.length >= LOG_BUFFER_FLUSH_SIZE) { flushPluginLogBuffer().catch((err) => { console.error("[plugin-host-services] Triggered log flush failed:", err); }); } }, }, tracer: { async record(params, context) { // The host trust boundary: validate the host-minted `traceparent`, // re-clamp the span name and every attribute, mint the parentage // host-side, and record the span through the real tracer. The capability // gate in `createHostClientHandlers` already rejected an ungranted // plugin before this runs. recordWorkerProviderSpan(params, context); }, }, companies: { async list(params) { return applyWindow((await companies.list()) as Company[], params); }, async get(params) { await ensurePluginAvailableForCompany(params.companyId); return (await companies.getById(params.companyId)) as Company; }, }, projects: { async list(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); return applyWindow((await projects.list(companyId)) as Project[], params); }, async get(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const project = await projects.getById(params.projectId); return (inCompany(project, companyId) ? project : null) as Project | null; }, async listWorkspaces(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const project = await projects.getById(params.projectId); if (!inCompany(project, companyId)) return []; const rows = await projects.listWorkspaces(params.projectId); return rows.map((row) => { const path = sanitizeWorkspacePath(row.cwd); const name = sanitizeWorkspaceName(row.name, path); return { id: row.id, projectId: row.projectId, name, path, repoUrl: row.repoUrl, repoRef: row.repoRef, defaultRef: row.defaultRef, isPrimary: row.isPrimary, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; }); }, async getPrimaryWorkspace(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const project = await projects.getById(params.projectId); if (!inCompany(project, companyId)) return null; const row = project.primaryWorkspace; const path = sanitizeWorkspacePath(project.codebase.effectiveLocalFolder); const name = sanitizeWorkspaceName(row?.name ?? project.name, path); return { id: row?.id ?? `${project.id}:managed`, projectId: project.id, name, path, repoUrl: row?.repoUrl ?? project.codebase.repoUrl, repoRef: row?.repoRef ?? project.codebase.repoRef, defaultRef: row?.defaultRef ?? project.codebase.defaultRef, isPrimary: true, createdAt: (row?.createdAt ?? project.createdAt).toISOString(), updatedAt: (row?.updatedAt ?? project.updatedAt).toISOString(), }; }, async getWorkspaceForIssue(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const issue = await issues.getById(params.issueId); if (!inCompany(issue, companyId)) return null; const projectId = (issue as Record).projectId as string | null; if (!projectId) return null; const project = await projects.getById(projectId); if (!inCompany(project, companyId)) return null; const row = project.primaryWorkspace; const path = sanitizeWorkspacePath(project.codebase.effectiveLocalFolder); const name = sanitizeWorkspaceName(row?.name ?? project.name, path); return { id: row?.id ?? `${project.id}:managed`, projectId: project.id, name, path, repoUrl: row?.repoUrl ?? project.codebase.repoUrl, repoRef: row?.repoRef ?? project.codebase.repoRef, defaultRef: row?.defaultRef ?? project.codebase.defaultRef, isPrimary: true, createdAt: (row?.createdAt ?? project.createdAt).toISOString(), updatedAt: (row?.updatedAt ?? project.updatedAt).toISOString(), }; }, async getManaged(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); return projects.resolveManagedProject({ companyId, pluginId, pluginKey, projectKey: params.projectKey, createIfMissing: false, }); }, async reconcileManaged(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); return projects.resolveManagedProject({ companyId, pluginId, pluginKey, projectKey: params.projectKey, }); }, async resetManaged(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); return projects.resolveManagedProject({ companyId, pluginId, pluginKey, projectKey: params.projectKey, reset: true, }); }, }, executionWorkspaces: { async get(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const workspace = await executionWorkspaces.getById(params.workspaceId); if (inCompany(workspace, companyId)) { return toPluginExecutionWorkspaceMetadata(workspace); } return null; }, }, routines: { async managedGet(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); return managedRoutines.get(params.routineKey, companyId); }, async managedReconcile(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); return managedRoutines.reconcile(params.routineKey, companyId, { assigneeAgentId: params.assigneeAgentId, projectId: params.projectId, }); }, async managedReset(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); return managedRoutines.reset(params.routineKey, companyId, { assigneeAgentId: params.assigneeAgentId, projectId: params.projectId, }); }, async managedUpdate(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); return managedRoutines.update(params.routineKey, companyId, { status: params.status, }); }, async managedRun(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); return managedRoutines.run(params.routineKey, companyId, { assigneeAgentId: params.assigneeAgentId, projectId: params.projectId, }); }, }, skills: { async managedGet(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); return managedSkills.get(params.skillKey, companyId); }, async managedReconcile(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); return managedSkills.reconcile(params.skillKey, companyId); }, async managedReset(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); return managedSkills.reset(params.skillKey, companyId); }, }, issues: { async list(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); assertReadableOriginFilter(params.originKind); return applyWindow((await issues.list(companyId, params as any)) as Issue[], params); }, async get(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const issue = await issues.getById(params.issueId); return (inCompany(issue, companyId) ? issue : null) as Issue | null; }, async create(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const { actorAgentId, actorUserId, actorRunId, originKind, surfaceVisibility, ...issueInput } = params; const normalizedOriginKind = normalizePluginOriginKind( surfaceVisibility === "plugin_operation" && !originKind ? pluginOperationIssueOriginKind(pluginKey) : originKind, ); const issue = (await issues.create(companyId, { ...(issueInput as any), originKind: normalizedOriginKind, originId: params.originId ?? null, originRunId: params.originRunId ?? actorRunId ?? null, createdByAgentId: actorAgentId ?? null, createdByUserId: actorUserId ?? null, actorResponsibleUserId: actorUserId ?? null, trustExplicitResponsibleUserId: true, })) as Issue; await logPluginActivity({ companyId, action: "issue.created", entityType: "issue", entityId: issue.id, actor: { actorAgentId, actorUserId, actorRunId }, details: { title: issue.title, identifier: issue.identifier, originKind: normalizedOriginKind, originId: issue.originId, billingCode: issue.billingCode, blockedByIssueIds: params.blockedByIssueIds ?? [], }, }); return issue; }, async update(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const existing = requireInCompany("Issue", await issues.getById(params.issueId), companyId); const patch = { ...(params.patch as Record) }; const actorAgentId = typeof patch.actorAgentId === "string" ? patch.actorAgentId : null; const actorUserId = typeof patch.actorUserId === "string" ? patch.actorUserId : null; const actorRunId = typeof patch.actorRunId === "string" ? patch.actorRunId : null; delete patch.actorAgentId; delete patch.actorUserId; delete patch.actorRunId; if (patch.originKind !== undefined) { patch.originKind = normalizePluginOriginKind(patch.originKind); } const updated = (await issues.update(params.issueId, { ...(patch as any), actorAgentId, actorUserId, })) as Issue; await logPluginActivity({ companyId, action: "issue.updated", entityType: "issue", entityId: updated.id, actor: { actorAgentId, actorUserId, actorRunId }, details: { identifier: updated.identifier, patch, _previous: { status: existing.status, assigneeAgentId: existing.assigneeAgentId, assigneeUserId: existing.assigneeUserId, }, }, }); return updated; }, async getRelations(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); requireInCompany("Issue", await issues.getById(params.issueId), companyId); return await issues.getRelationSummaries(params.issueId); }, async setBlockedBy(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); return setBlockedByWithActivity({ companyId, issueId: params.issueId, blockedByIssueIds: params.blockedByIssueIds, mutation: "set", actorAgentId: params.actorAgentId, actorUserId: params.actorUserId, actorRunId: params.actorRunId, }); }, async addBlockers(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); requireInCompany("Issue", await issues.getById(params.issueId), companyId); const previous = await issues.getRelationSummaries(params.issueId); const nextBlockedByIssueIds = [ ...new Set([ ...previous.blockedBy.map((relation) => relation.id), ...params.blockerIssueIds, ]), ]; return setBlockedByWithActivity({ companyId, issueId: params.issueId, blockedByIssueIds: nextBlockedByIssueIds, mutation: "add", actorAgentId: params.actorAgentId, actorUserId: params.actorUserId, actorRunId: params.actorRunId, }); }, async removeBlockers(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); requireInCompany("Issue", await issues.getById(params.issueId), companyId); const previous = await issues.getRelationSummaries(params.issueId); const removals = new Set(params.blockerIssueIds); const nextBlockedByIssueIds = previous.blockedBy .map((relation) => relation.id) .filter((issueId) => !removals.has(issueId)); return setBlockedByWithActivity({ companyId, issueId: params.issueId, blockedByIssueIds: nextBlockedByIssueIds, mutation: "remove", actorAgentId: params.actorAgentId, actorUserId: params.actorUserId, actorRunId: params.actorRunId, }); }, async assertCheckoutOwner(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); requireInCompany("Issue", await issues.getById(params.issueId), companyId); const ownership = await issues.assertCheckoutOwner( params.issueId, params.actorAgentId, params.actorRunId, ); if (ownership.adoptedFromRunId) { await logPluginActivity({ companyId, action: "issue.checkout_lock_adopted", entityType: "issue", entityId: params.issueId, actor: { actorAgentId: params.actorAgentId, actorRunId: params.actorRunId, }, details: { previousCheckoutRunId: ownership.adoptedFromRunId, checkoutRunId: params.actorRunId, reason: "stale_checkout_run", }, }); } return { issueId: ownership.id, status: ownership.status as Issue["status"], assigneeAgentId: ownership.assigneeAgentId, checkoutRunId: ownership.checkoutRunId, adoptedFromRunId: ownership.adoptedFromRunId, }; }, async getSubtree(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const rootIssue = requireInCompany("Issue", await issues.getById(params.issueId), companyId); const includeRoot = params.includeRoot !== false; const subtreeIssueIds = await collectIssueSubtreeIds(companyId, rootIssue.id); const issueIds = includeRoot ? subtreeIssueIds : subtreeIssueIds.filter((issueId) => issueId !== rootIssue.id); const issueRows = issueIds.length > 0 ? await db .select() .from(issuesTable) .where(and(eq(issuesTable.companyId, companyId), inArray(issuesTable.id, issueIds))) : []; const issuesById = new Map(issueRows.map((issue) => [issue.id, issue as Issue])); const outputIssues = issueIds .map((issueId) => issuesById.get(issueId)) .filter((issue): issue is Issue => Boolean(issue)); const assigneeAgentIds = [ ...new Set(outputIssues.map((issue) => issue.assigneeAgentId).filter((id): id is string => Boolean(id))), ]; const [relationPairs, documentPairs, activeRunRows, assigneeRows] = await Promise.all([ params.includeRelations ? Promise.all(issueIds.map(async (issueId) => [issueId, await issues.getRelationSummaries(issueId)] as const)) : Promise.resolve(null), params.includeDocuments ? Promise.all( issueIds.map(async (issueId) => { const docs = await documents.listIssueDocuments(issueId); const summaries: IssueDocumentSummary[] = docs.map((document) => { const { body: _body, ...summary } = document as typeof document & { body?: string }; return { ...summary, format: "markdown" as const }; }); return [ issueId, summaries, ] as const; }), ) : Promise.resolve(null), params.includeActiveRuns ? getIssueRunSummaries(companyId, issueIds, { activeOnly: true }) : Promise.resolve(null), params.includeAssignees && assigneeAgentIds.length > 0 ? db .select({ id: agentsTable.id, name: agentsTable.name, role: agentsTable.role, title: agentsTable.title, status: agentsTable.status, }) .from(agentsTable) .where(and(eq(agentsTable.companyId, companyId), inArray(agentsTable.id, assigneeAgentIds))) : Promise.resolve(params.includeAssignees ? [] : null), ]); const activeRuns = activeRunRows ? Object.fromEntries(issueIds.map((issueId) => [ issueId, activeRunRows.filter((run) => run.issueId === issueId), ])) : undefined; return { rootIssueId: rootIssue.id, companyId, issueIds, issues: outputIssues, ...(relationPairs ? { relations: Object.fromEntries(relationPairs) } : {}), ...(documentPairs ? { documents: Object.fromEntries(documentPairs) } : {}), ...(activeRuns ? { activeRuns } : {}), ...(assigneeRows ? { assignees: Object.fromEntries(assigneeRows.map((agent) => [ agent.id, { ...agent, status: agent.status as Agent["status"] } as PluginIssueAssigneeSummary, ])), } : {}), }; }, async requestWakeup(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const issue = requireInCompany("Issue", await issues.getById(params.issueId), companyId); if (!issue.assigneeAgentId) { throw new Error("Issue has no assigned agent to wake"); } if (["backlog", "done", "cancelled"].includes(issue.status)) { throw new Error(`Issue is not wakeable in status: ${issue.status}`); } const relations = await issues.getRelationSummaries(issue.id); const unresolvedBlockers = relations.blockedBy.filter((blocker) => blocker.status !== "done"); if (unresolvedBlockers.length > 0) { throw new Error("Issue is blocked by unresolved blockers"); } const budgetBlock = await budgets.getInvocationBlock(companyId, issue.assigneeAgentId, { issueId: issue.id, projectId: issue.projectId, }); if (budgetBlock) { throw new Error(budgetBlock.reason); } const contextSource = params.contextSource ?? "plugin.issue.requestWakeup"; const run = await heartbeat.wakeup(issue.assigneeAgentId, { source: "assignment", triggerDetail: "system", reason: params.reason ?? "plugin_issue_wakeup_requested", payload: { issueId: issue.id, mutation: "plugin_wakeup", pluginId, pluginKey, contextSource, }, idempotencyKey: params.idempotencyKey ?? null, requestedByActorType: "system", requestedByActorId: pluginId, contextSnapshot: { issueId: issue.id, taskId: issue.id, wakeReason: params.reason ?? "plugin_issue_wakeup_requested", source: contextSource, pluginId, pluginKey, }, }); await logPluginActivity({ companyId, action: "issue.assignment_wakeup_requested", entityType: "issue", entityId: issue.id, actor: { actorAgentId: params.actorAgentId, actorUserId: params.actorUserId, actorRunId: params.actorRunId, }, details: { identifier: issue.identifier, assigneeAgentId: issue.assigneeAgentId, runId: run?.id ?? null, reason: params.reason ?? "plugin_issue_wakeup_requested", contextSource, }, }); return { queued: Boolean(run), runId: run?.id ?? null }; }, async requestWakeups(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const results = []; for (const issueId of [...new Set(params.issueIds)]) { const issue = requireInCompany("Issue", await issues.getById(issueId), companyId); if (!issue.assigneeAgentId) { throw new Error("Issue has no assigned agent to wake"); } if (["backlog", "done", "cancelled"].includes(issue.status)) { throw new Error(`Issue is not wakeable in status: ${issue.status}`); } const relations = await issues.getRelationSummaries(issue.id); const unresolvedBlockers = relations.blockedBy.filter((blocker) => blocker.status !== "done"); if (unresolvedBlockers.length > 0) { throw new Error("Issue is blocked by unresolved blockers"); } const budgetBlock = await budgets.getInvocationBlock(companyId, issue.assigneeAgentId, { issueId: issue.id, projectId: issue.projectId, }); if (budgetBlock) { throw new Error(budgetBlock.reason); } const contextSource = params.contextSource ?? "plugin.issue.requestWakeups"; const run = await heartbeat.wakeup(issue.assigneeAgentId, { source: "assignment", triggerDetail: "system", reason: params.reason ?? "plugin_issue_wakeup_requested", payload: { issueId: issue.id, mutation: "plugin_wakeup", pluginId, pluginKey, contextSource, }, idempotencyKey: params.idempotencyKeyPrefix ? `${params.idempotencyKeyPrefix}:${issue.id}` : null, requestedByActorType: "system", requestedByActorId: pluginId, contextSnapshot: { issueId: issue.id, taskId: issue.id, wakeReason: params.reason ?? "plugin_issue_wakeup_requested", source: contextSource, pluginId, pluginKey, }, }); await logPluginActivity({ companyId, action: "issue.assignment_wakeup_requested", entityType: "issue", entityId: issue.id, actor: { actorAgentId: params.actorAgentId, actorUserId: params.actorUserId, actorRunId: params.actorRunId, }, details: { identifier: issue.identifier, assigneeAgentId: issue.assigneeAgentId, runId: run?.id ?? null, reason: params.reason ?? "plugin_issue_wakeup_requested", contextSource, }, }); results.push({ issueId: issue.id, queued: Boolean(run), runId: run?.id ?? null }); } return results; }, async getOrchestrationSummary(params): Promise { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const rootIssue = requireInCompany("Issue", await issues.getById(params.issueId), companyId); const subtreeIssueIds = params.includeSubtree ? await collectIssueSubtreeIds(companyId, rootIssue.id) : [rootIssue.id]; const relationPairs = await Promise.all( subtreeIssueIds.map(async (issueId) => [issueId, await issues.getRelationSummaries(issueId)] as const), ); const approvalRows = ( await Promise.all( subtreeIssueIds.map(async (issueId) => { const rows = await issueApprovals.listApprovalsForIssue(issueId); return rows.map((approval) => ({ issueId, id: approval.id, type: approval.type, status: approval.status, requestedByAgentId: approval.requestedByAgentId, requestedByUserId: approval.requestedByUserId, decidedByUserId: approval.decidedByUserId, decidedAt: approval.decidedAt?.toISOString() ?? null, createdAt: approval.createdAt.toISOString(), })); }), ) ).flat(); const [runs, costsSummary, openBudgetIncidents] = await Promise.all([ getIssueRunSummaries(companyId, subtreeIssueIds), getIssueCostSummary(companyId, subtreeIssueIds, params.billingCode ?? rootIssue.billingCode ?? null), getOpenBudgetIncidents(companyId), ]); const issueRows = await db .select({ id: issuesTable.id, assigneeAgentId: issuesTable.assigneeAgentId, projectId: issuesTable.projectId, }) .from(issuesTable) .where(and(eq(issuesTable.companyId, companyId), inArray(issuesTable.id, subtreeIssueIds))); const invocationBlocks = ( await Promise.all( issueRows .filter((issueRow) => issueRow.assigneeAgentId) .map(async (issueRow) => { const block = await budgets.getInvocationBlock(companyId, issueRow.assigneeAgentId!, { issueId: issueRow.id, projectId: issueRow.projectId, }); return block ? { issueId: issueRow.id, agentId: issueRow.assigneeAgentId!, scopeType: block.scopeType, scopeId: block.scopeId, scopeName: block.scopeName, reason: block.reason, } : null; }), ) ).filter((block): block is NonNullable => block !== null); return { issueId: rootIssue.id, companyId, subtreeIssueIds, relations: Object.fromEntries(relationPairs), approvals: approvalRows, runs, costs: costsSummary, openBudgetIncidents, invocationBlocks, }; }, async listComments(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); if (!inCompany(await issues.getById(params.issueId), companyId)) return []; return (await issues.listComments(params.issueId)) as IssueComment[]; }, async createComment(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const issue = requireInCompany("Issue", await issues.getById(params.issueId), companyId); if (params.actorUserId) { await requireActiveHumanMember(companyId, params.actorUserId); } const comment = (await issues.addComment( params.issueId, params.body, { agentId: params.actorUserId ? undefined : params.authorAgentId, userId: params.actorUserId }, )) as IssueComment; await logPluginActivity({ companyId, action: "issue.comment.created", entityType: "issue", entityId: issue.id, actor: { actorAgentId: params.actorUserId ? null : params.authorAgentId ?? null, actorUserId: params.actorUserId ?? null }, details: { identifier: issue.identifier, commentId: comment.id, bodySnippet: comment.body.slice(0, 120), }, }); // Human-attributed comments participate in the same "wake the // assignee" behavior a board user's comment gets in the web app // (routes/issues.ts's addComment route) — a plugin's own // agent-attributed comments never do this. Deliberately narrower // than the HTTP route: no reopen/resume/interrupt/scheduled-retry // handling here, just the core wake. An assignee-less or // closed-status issue is a silent no-op, matching the route's own // guard. // // The guard re-fetches the issue instead of trusting the pre-insert // `issue` snapshot: a concurrent close/unassign/reassign landing // between the initial fetch and here would otherwise wake the wrong // (or no-longer-relevant) agent off stale state. // // The comment is already committed above, so this best-effort wake // must never change that outcome: a failed re-fetch is logged and // falls back to the in-hand snapshot rather than rejecting // createComment — a rejection would surface to the caller as a failed // write and invite a retry that inserts a duplicate comment. if (params.actorUserId) { const postCommentIssue = (await issues.getById(issue.id).catch((err) => { logger.warn( { err, issueId: issue.id, commentId: comment.id }, "failed to re-fetch issue for plugin-relayed human comment wake; falling back to pre-insert snapshot", ); return null; })) ?? issue; if ( postCommentIssue.assigneeAgentId && postCommentIssue.status !== "done" && postCommentIssue.status !== "cancelled" ) { await heartbeat.wakeup(postCommentIssue.assigneeAgentId, { source: "automation", triggerDetail: "system", reason: "issue_commented", payload: { issueId: issue.id, commentId: comment.id, mutation: "comment", }, requestedByActorType: "user", requestedByActorId: params.actorUserId, contextSnapshot: { issueId: issue.id, taskId: issue.id, sourceCommentId: comment.id, wakeReason: "issue_commented", source: `plugin:${pluginKey}`, }, }).catch((err) => logger.warn({ err, issueId: issue.id, commentId: comment.id, agentId: postCommentIssue.assigneeAgentId, }, "failed to wake assignee on plugin-relayed human comment")); } } return comment; }, async createInteraction(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const issue = requireInCompany("Issue", await issues.getById(params.issueId), companyId); const interaction = await issueThreadInteractionService(db).create(issue, params.interaction as CreateIssueThreadInteraction, { agentId: params.authorAgentId ?? null, }); await logPluginActivity({ companyId, action: "issue.thread_interaction_created", entityType: "issue", entityId: issue.id, actor: { actorAgentId: params.authorAgentId ?? null }, details: { identifier: issue.identifier, interactionId: interaction.id, interactionKind: interaction.kind, interactionStatus: interaction.status, continuationPolicy: interaction.continuationPolicy, }, }); return interaction as any; }, async listInteractions(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); if (!inCompany(await issues.getById(params.issueId), companyId)) return []; return (await interactions.listForIssue(params.issueId)) as any; }, async respondInteraction(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const issue = requireInCompany("Issue", await issues.getById(params.issueId), companyId); // Resolving an interaction is a board-user action (the web app's // interaction-resolve routes are board-only). The host re-verifies the // paired user is an active human member at apply time and never trusts // the plugin-supplied identity — matching the createComment bar. if (!params.actorUserId) { throw new Error("actorUserId is required to respond to an interaction on behalf of a board user"); } await requireActiveHumanMember(companyId, params.actorUserId); const current = await interactions.getById(params.interactionId); if (!current || current.issueId !== issue.id || current.companyId !== companyId) { throw new Error(`Interaction "${params.interactionId}" not found for this issue`); } // Idempotent replay: an already-resolved interaction converges without // re-applying, so a duplicate button tap from chat is a safe no-op. if (current.status !== "pending") { return { interaction: current as any, applied: false }; } const actor = { userId: params.actorUserId }; let resolved: typeof current; let continuationTarget = { id: issue.id, assigneeAgentId: issue.assigneeAgentId, status: issue.status, }; if (params.action === "accept") { const result = await interactions.acceptInteraction( { id: issue.id, companyId, projectId: issue.projectId ?? null, goalId: issue.goalId ?? null, status: issue.status, }, params.interactionId, {}, actor, ); resolved = result.interaction as typeof current; if (result.continuationIssue) { continuationTarget = { id: result.continuationIssue.id, assigneeAgentId: result.continuationIssue.assigneeAgentId, status: result.continuationIssue.status, }; } } else { resolved = (await interactions.rejectInteraction( { id: issue.id, companyId, status: issue.status }, params.interactionId, { reason: params.reason ?? undefined }, actor, )) as typeof current; } await logPluginActivity({ companyId, action: params.action === "accept" ? "issue.thread_interaction_accepted" : "issue.thread_interaction_rejected", entityType: "issue", entityId: issue.id, actor: { actorUserId: params.actorUserId }, details: { identifier: issue.identifier, interactionId: resolved.id, interactionKind: resolved.kind, interactionStatus: resolved.status, }, }); queuePluginInteractionContinuationWakeup({ issue: continuationTarget, interaction: resolved, actorUserId: params.actorUserId, source: `plugin:${pluginKey}:interaction.${params.action}`, }); return { interaction: resolved as any, applied: true }; }, async listAttachments(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); if (!inCompany(await issues.getById(params.issueId), companyId)) return []; return (await issues.listAttachments(params.issueId)) as any; }, async getAttachmentContent(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const attachment = await issues.getAttachmentById(params.attachmentId); // Unknown and cross-company ids are deliberately indistinguishable to // the plugin: both return null (no existence oracle across companies). if (!attachment || attachment.companyId !== companyId) return null; const maxBytes = typeof params.maxBytes === "number" && params.maxBytes > 0 ? params.maxBytes : null; if (maxBytes !== null && attachment.byteSize > maxBytes) { throw new Error( `attachment ${attachment.id} is ${attachment.byteSize} bytes, over the ${maxBytes}-byte cap`, ); } const object = await getStorageService().getObject(attachment.companyId, attachment.objectKey); const chunks: Buffer[] = []; let total = 0; for await (const chunk of object.stream) { const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); total += buf.length; // Defense in depth: enforce the cap during streaming too, so a // metadata/object size mismatch can never exceed the requested cap. if (maxBytes !== null && total > maxBytes) { object.stream.destroy(); throw new Error(`attachment ${attachment.id} exceeded the ${maxBytes}-byte cap while reading`); } chunks.push(buf); } const bytes = Buffer.concat(chunks); await logPluginActivity({ companyId, action: "issue.attachment.read", entityType: "issue", entityId: attachment.issueId, details: { attachmentId: attachment.id, byteSize: bytes.length, contentType: attachment.contentType, }, }); return { attachmentId: attachment.id, contentType: attachment.contentType, byteSize: bytes.length, sha256: attachment.sha256, originalFilename: attachment.originalFilename ?? null, contentBase64: bytes.toString("base64"), }; }, }, approvals: { async list(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const rows = await approvalSvc.list(companyId, params.status ?? undefined); // Match the web app's approval read surface: payloads are redacted so // the chat bridge never receives secrets the web app itself hides. return rows.map((approval) => redactApprovalPayload(approval)) as any; }, async get(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const approval = await approvalSvc.getById(params.approvalId); if (!approval || approval.companyId !== companyId) return null; return redactApprovalPayload(approval) as any; }, async decide(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const existing = await approvalSvc.getById(params.approvalId); if (!existing || existing.companyId !== companyId) { throw new Error(`Approval "${params.approvalId}" not found`); } // Deciding an approval is a board-user action (the web app's approval // decision routes are board-only). Re-verify active membership at apply // time; never trust the plugin-supplied identity. if (!params.actorUserId) { throw new Error("actorUserId is required to decide an approval on behalf of a board user"); } await requireActiveHumanMember(companyId, params.actorUserId); const { approval, applied } = params.action === "approve" ? await approvalSvc.approve(params.approvalId, params.actorUserId, params.decisionNote ?? null) : await approvalSvc.reject(params.approvalId, params.actorUserId, params.decisionNote ?? null); await logPluginActivity({ companyId, action: params.action === "approve" ? "approval.approved" : "approval.rejected", entityType: "approval", entityId: approval.id, actor: { actorUserId: params.actorUserId }, details: { type: approval.type, requestedByAgentId: approval.requestedByAgentId, applied, }, }); // Mirror the web app's approve/reject routes: wake the requesting agent // so it resumes after a chat-driven decision. Only on a fresh decision // (applied) and only when a requester agent exists. if (applied && approval.requestedByAgentId) { void heartbeat.wakeup(approval.requestedByAgentId, { source: "automation", triggerDetail: "system", reason: params.action === "approve" ? "approval_approved" : "approval_rejected", payload: { approvalId: approval.id, approvalStatus: approval.status, }, requestedByActorType: "user", requestedByActorId: params.actorUserId, contextSnapshot: { source: `plugin:${pluginKey}:approval.${params.action}`, approvalId: approval.id, approvalStatus: approval.status, wakeReason: params.action === "approve" ? "approval_approved" : "approval_rejected", }, }).catch((err) => logger.warn({ err, approvalId: approval.id, requestedByAgentId: approval.requestedByAgentId, }, "failed to wake requester on plugin-relayed approval decision")); } return { approval: redactApprovalPayload(approval) as any, applied }; }, }, issueDocuments: { async list(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); requireInCompany("Issue", await issues.getById(params.issueId), companyId); const rows = await documents.listIssueDocuments(params.issueId); return rows as any; }, async get(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); requireInCompany("Issue", await issues.getById(params.issueId), companyId); const doc = await documents.getIssueDocumentByKey(params.issueId, params.key); return (doc ?? null) as any; }, async upsert(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const issue = requireInCompany("Issue", await issues.getById(params.issueId), companyId); const result = await documents.upsertIssueDocument({ issueId: params.issueId, key: params.key, body: params.body, title: params.title ?? null, format: params.format ?? "markdown", changeSummary: params.changeSummary ?? null, }); await logPluginActivity({ companyId, action: "issue.document_upserted", entityType: "issue", entityId: issue.id, details: { identifier: issue.identifier, documentKey: params.key, title: params.title ?? null, format: params.format ?? "markdown", }, }); return result.document as any; }, async delete(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const issue = requireInCompany("Issue", await issues.getById(params.issueId), companyId); await documents.deleteIssueDocument(params.issueId, params.key); await logPluginActivity({ companyId, action: "issue.document_deleted", entityType: "issue", entityId: issue.id, details: { identifier: issue.identifier, documentKey: params.key, }, }); }, }, agents: { async list(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const rows = await agents.list(companyId); return applyWindow( rows.filter((agent) => !params.status || agent.status === params.status) as Agent[], params, ); }, async get(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const agent = await agents.getById(params.agentId); return (inCompany(agent, companyId) ? agent : null) as Agent | null; }, async pause(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const agent = await agents.getById(params.agentId); requireInCompany("Agent", agent, companyId); return (await agents.pause(params.agentId)) as Agent; }, async resume(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const agent = await agents.getById(params.agentId); requireInCompany("Agent", agent, companyId); return (await agents.resume(params.agentId)) as Agent; }, async invoke(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const agent = await agents.getById(params.agentId); requireInCompany("Agent", agent, companyId); const run = await heartbeat.wakeup(params.agentId, { source: "automation", triggerDetail: "system", reason: params.reason ?? null, payload: { prompt: params.prompt }, contextSnapshot: { wakeReason: params.reason ?? null, paperclipAgentMessage: { text: params.prompt, source: "plugin_invoke", pluginKey, }, }, requestedByActorType: "system", requestedByActorId: pluginId, }); if (!run) throw new Error("Agent wakeup was skipped by heartbeat policy"); return { runId: run.id }; }, async managedGet(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); return managedAgents.get(params.agentKey, companyId); }, async managedReconcile(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); return managedAgents.reconcile(params.agentKey, companyId); }, async managedReset(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); return managedAgents.reset(params.agentKey, companyId); }, }, goals: { async list(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const rows = await goals.list(companyId); return applyWindow( rows.filter((goal) => (!params.level || goal.level === params.level) && (!params.status || goal.status === params.status), ) as Goal[], params, ); }, async get(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const goal = await goals.getById(params.goalId); return (inCompany(goal, companyId) ? goal : null) as Goal | null; }, async create(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); return (await goals.create(companyId, { title: params.title, description: params.description, level: params.level as any, status: params.status as any, parentId: params.parentId, ownerAgentId: params.ownerAgentId, })) as Goal; }, async update(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); requireInCompany("Goal", await goals.getById(params.goalId), companyId); return (await goals.update(params.goalId, params.patch as any)) as Goal; }, }, access: { async listMembers(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const rows = await access.listMembers(companyId); const visibleRows = params.includeArchived ? rows : rows.filter((row) => row.status !== "archived"); const grants = await db .select() .from(principalPermissionGrants) .where(eq(principalPermissionGrants.companyId, companyId)); const grantsByPrincipal = new Map(); for (const grant of grants) { const key = `${grant.principalType}:${grant.principalId}`; const existing = grantsByPrincipal.get(key) ?? []; existing.push(grant); grantsByPrincipal.set(key, existing); } return visibleRows.map((member) => ({ ...member, principalType: member.principalType as PrincipalType, status: member.status as "pending" | "active" | "suspended" | "archived", grants: (grantsByPrincipal.get(`${member.principalType}:${member.principalId}`) ?? []).map(redactGrant), })); }, async getMember(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); return loadPluginMember(companyId, params.memberId); }, async updateMember(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const updated = await access.updateMember(companyId, params.memberId, params.patch); if (!updated) throw new Error("Member not found"); await logPluginActivity({ companyId, action: "company_member.updated_by_plugin", entityType: "company_membership", entityId: params.memberId, details: { patch: sanitizeRecord(params.patch as Record), }, }); return (await loadPluginMember(companyId, params.memberId))!; }, async listInvites(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const limit = Math.min(Math.max(Number(params.limit ?? 20), 1), 100); const offset = Math.max(Number(params.offset ?? 0), 0); const stateClause = inviteStateWhereClause(params.state); const rows = await db .select() .from(invites) .where(stateClause ? and(eq(invites.companyId, companyId), stateClause) : eq(invites.companyId, companyId)) .orderBy(desc(invites.createdAt)) .limit(limit + 1) .offset(offset); const hasMore = rows.length > limit; return { invites: rows.slice(0, limit).map(redactInvite), nextOffset: hasMore ? offset + limit : null, }; }, async createInvite(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const normalizedAgentMessage = typeof params.agentMessage === "string" ? params.agentMessage.trim() || null : null; const allowedJoinTypes = params.allowedJoinTypes ?? "both"; const humanRole = allowedJoinTypes === "agent" ? null : params.humanRole ?? "operator"; const insertValues = { companyId, inviteType: "company_join" as const, allowedJoinTypes, defaultsPayload: mergeInviteDefaults(params.defaultsPayload ?? null, normalizedAgentMessage, humanRole), expiresAt: new Date(Date.now() + COMPANY_INVITE_TTL_MS), invitedByUserId: null, }; let token: string | null = null; let created: typeof invites.$inferSelect | null = null; for (let attempt = 0; attempt < INVITE_TOKEN_MAX_RETRIES; attempt += 1) { const candidateToken = createInviteToken(); try { created = await db .insert(invites) .values({ ...insertValues, tokenHash: hashToken(candidateToken), }) .returning() .then((rows) => rows[0] ?? null); token = candidateToken; break; } catch (error) { if (!isInviteTokenHashCollisionError(error)) throw error; } } if (!token || !created) throw new Error("Failed to generate a unique invite token"); await logPluginActivity({ companyId, action: "invite.created_by_plugin", entityType: "invite", entityId: created.id, details: { allowedJoinTypes: created.allowedJoinTypes, expiresAt: created.expiresAt.toISOString(), hasAgentMessage: Boolean(normalizedAgentMessage), }, }); return { ...redactInvite(created), token }; }, async revokeInvite(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const invite = await db .select() .from(invites) .where(and(eq(invites.id, params.inviteId), eq(invites.companyId, companyId))) .then((rows) => rows[0] ?? null); if (!invite) throw new Error("Invite not found"); if (invite.acceptedAt) throw new Error("Invite already consumed"); if (invite.revokedAt) return redactInvite(invite); const revoked = await db .update(invites) .set({ revokedAt: new Date(), updatedAt: new Date() }) .where(eq(invites.id, invite.id)) .returning() .then((rows) => rows[0] ?? invite); await logPluginActivity({ companyId, action: "invite.revoked_by_plugin", entityType: "invite", entityId: invite.id, }); return redactInvite(revoked); }, }, authorization: { async listGrants(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const conditions = [ eq(principalPermissionGrants.companyId, companyId), params.principalType ? eq(principalPermissionGrants.principalType, params.principalType) : undefined, params.principalId ? eq(principalPermissionGrants.principalId, params.principalId) : undefined, ].filter((condition): condition is NonNullable => Boolean(condition)); const rows = await db .select() .from(principalPermissionGrants) .where(and(...conditions)) .orderBy(principalPermissionGrants.principalType, principalPermissionGrants.principalId, principalPermissionGrants.permissionKey); return rows.map(redactGrant); }, async setGrants(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); if (params.principalType !== "agent" && params.principalType !== "user") { throw new Error("principalType must be 'agent' or 'user'"); } if (params.principalType === "agent") { requireInCompany("Agent", await agents.getById(params.principalId), companyId); } else { const membership = await access.getMembership(companyId, params.principalType as PrincipalType, params.principalId); if (!membership) throw new Error("Principal is not a member of this company"); } await access.setPrincipalGrants( companyId, params.principalType as PrincipalType, params.principalId, params.grants.map((grant) => ({ permissionKey: grant.permissionKey as PermissionKey, scope: grant.scope ? sanitizeRecord(grant.scope) : null, })), params.grantedByUserId ?? null, ); await logPluginActivity({ companyId, action: "authorization.grants_updated_by_plugin", entityType: "principal_permission_grants", entityId: `${params.principalType}:${params.principalId}`, details: { grantCount: params.grants.length }, }); return access .listPrincipalGrants(companyId, params.principalType as PrincipalType, params.principalId) .then((rows) => rows.map(redactGrant)); }, async policySummary(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const [members, grants] = await Promise.all([ access.listMembers(companyId), db .select({ id: principalPermissionGrants.id }) .from(principalPermissionGrants) .where(eq(principalPermissionGrants.companyId, companyId)), ]); return { companyId, permissionsMode: "simple" as const, memberCount: members.length, activeMemberCount: members.filter((member) => member.status === "active").length, grantCount: grants.length, advancedPolicyAvailable: false as const, }; }, async getPolicy(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); return readAuthorizationPolicy(companyId, params.resourceType, params.resourceId); }, async updatePolicy(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const policy = params.policy ? sanitizeRecord(params.policy) : null; if (params.resourceType === "agent") { const agent = requireInCompany("Agent", await agents.getById(params.resourceId), companyId); const permissions = agent.permissions && typeof agent.permissions === "object" ? { ...(agent.permissions as Record) } : {}; if (policy) permissions.authorizationPolicy = policy; else delete permissions.authorizationPolicy; await db .update(agentsTable) .set({ permissions, updatedAt: new Date() }) .where(eq(agentsTable.id, agent.id)); } else if (params.resourceType === "project") { const project = requireInCompany("Project", await projects.getById(params.resourceId), companyId); const executionWorkspacePolicy = project.executionWorkspacePolicy && typeof project.executionWorkspacePolicy === "object" ? { ...(project.executionWorkspacePolicy as unknown as Record) } : {}; if (policy) executionWorkspacePolicy.authorizationPolicy = policy; else delete executionWorkspacePolicy.authorizationPolicy; await db .update(projectsTable) .set({ executionWorkspacePolicy, updatedAt: new Date() }) .where(eq(projectsTable.id, project.id)); } else if (params.resourceType === "issue") { const issue = requireInCompany("Issue", await issues.getById(params.resourceId), companyId); const executionPolicy = issue.executionPolicy && typeof issue.executionPolicy === "object" ? { ...(issue.executionPolicy as Record) } : {}; if (policy) executionPolicy.authorizationPolicy = policy; else delete executionPolicy.authorizationPolicy; await db .update(issuesTable) .set({ executionPolicy, updatedAt: new Date() }) .where(eq(issuesTable.id, issue.id)); } else { const company = await companies.getById(params.resourceId); if (!company || company.id !== companyId) throw new Error("Company not found"); throw new Error("Company authorization policy updates are not supported by the current core schema"); } await logPluginActivity({ companyId, action: "authorization.policy_updated_by_plugin", entityType: params.resourceType, entityId: params.resourceId, details: { hasPolicy: Boolean(policy) }, }); const updated = await readAuthorizationPolicy(companyId, params.resourceType, params.resourceId); if (!updated) throw new Error("Policy resource not found"); return updated; }, async previewAssignment(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); return authorization.decide({ actor: pluginAssignmentActor(params.actor), action: "tasks:assign", resource: { type: "issue", companyId, ...params.target }, scope: { issueId: params.target.issueId ?? null, projectId: params.target.projectId ?? null, parentIssueId: params.target.parentIssueId ?? null, assigneeAgentId: params.target.assigneeAgentId ?? null, assigneeUserId: params.target.assigneeUserId ?? null, }, }); }, async explainAssignment(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); return authorization.decide({ actor: pluginAssignmentActor(params.actor), action: "tasks:assign", resource: { type: "issue", companyId, ...params.target }, scope: { issueId: params.target.issueId ?? null, projectId: params.target.projectId ?? null, parentIssueId: params.target.parentIssueId ?? null, assigneeAgentId: params.target.assigneeAgentId ?? null, assigneeUserId: params.target.assigneeUserId ?? null, }, }); }, async searchAudit(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const limit = Math.min(Math.max(Number(params.limit ?? 50), 1), 100); const offset = Math.max(Number(params.offset ?? 0), 0); const decisionFilter = typeof params.decision === "string" && params.decision.trim() ? params.decision.trim().toLowerCase() : null; const conditions = [ eq(activityLog.companyId, companyId), params.action ? eq(activityLog.action, params.action) : undefined, params.actorType ? eq(activityLog.actorType, params.actorType) : undefined, params.actorId ? eq(activityLog.actorId, params.actorId) : undefined, params.entityType ? eq(activityLog.entityType, params.entityType) : undefined, params.entityId ? eq(activityLog.entityId, params.entityId) : undefined, decisionFilter ? authorizationAuditDecisionCondition(decisionFilter) : undefined, ].filter((condition): condition is NonNullable => Boolean(condition)); const rows = await db .select() .from(activityLog) .where(and(...conditions)) .orderBy(desc(activityLog.createdAt)) .limit(limit) .offset(offset); return rows.map((row) => ({ ...row, details: row.details && typeof row.details === "object" ? sanitizeRecord(row.details) : row.details ?? null, })); }, }, agentSessions: { async create(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const agent = await agents.getById(params.agentId); requireInCompany("Agent", agent, companyId); const taskKey = params.taskKey ?? `plugin:${pluginKey}:session:${randomUUID()}`; const row = await db .insert(agentTaskSessionsTable) .values({ companyId, agentId: params.agentId, adapterType: agent!.adapterType, taskKey, sessionParamsJson: null, sessionDisplayId: null, lastRunId: null, lastError: null, }) .returning() .then((rows) => rows[0]); return { sessionId: row!.id, agentId: params.agentId, companyId, status: "active" as const, createdAt: row!.createdAt.toISOString(), }; }, async list(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const rows = await db .select() .from(agentTaskSessionsTable) .where( and( eq(agentTaskSessionsTable.agentId, params.agentId), eq(agentTaskSessionsTable.companyId, companyId), like(agentTaskSessionsTable.taskKey, `plugin:${pluginKey}:session:%`), ), ) .orderBy(desc(agentTaskSessionsTable.createdAt)); return rows.map((row) => ({ sessionId: row.id, agentId: row.agentId, companyId: row.companyId, status: "active" as const, createdAt: row.createdAt.toISOString(), })); }, async sendMessage(params) { if (disposed) { throw new Error("Host services have been disposed"); } const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); // Verify session exists and belongs to this plugin const session = await db .select() .from(agentTaskSessionsTable) .where( and( eq(agentTaskSessionsTable.id, params.sessionId), eq(agentTaskSessionsTable.companyId, companyId), like(agentTaskSessionsTable.taskKey, `plugin:${pluginKey}:session:%`), ), ) .then((rows) => rows[0] ?? null); if (!session) throw new Error(`Session not found: ${params.sessionId}`); const run = await heartbeat.wakeup(session.agentId, { source: "automation", triggerDetail: "system", reason: params.reason ?? null, payload: { prompt: params.prompt }, contextSnapshot: { taskKey: session.taskKey, wakeReason: params.reason ?? null, wakeSource: "automation", wakeTriggerDetail: "system", paperclipAgentMessage: { text: params.prompt, source: "plugin_session", pluginKey, sessionId: params.sessionId, }, }, requestedByActorType: "system", requestedByActorId: pluginId, }); if (!run) throw new Error("Agent wakeup was skipped by heartbeat policy"); // Subscribe to live events and forward to the plugin worker as notifications. // Track the subscription so it can be cleaned up on dispose() if the run // never reaches a terminal status (hang, crash, network partition). if (notifyWorker) { const TERMINAL_STATUSES = new Set(["succeeded", "interrupted", "failed", "cancelled", "timed_out"]); const cleanup = () => { unsubscribe(); clearTimeout(timeoutTimer); activeSubscriptions.delete(entry); }; const unsubscribe = subscribeCompanyLiveEvents(companyId, (event) => { const payload = event.payload as Record | undefined; if (!payload || payload.runId !== run.id) return; if (event.type === "heartbeat.run.log" || event.type === "heartbeat.run.event") { notifyWorker("agents.sessions.event", { sessionId: params.sessionId, runId: run.id, seq: (payload.seq as number) ?? 0, eventType: "chunk", stream: (payload.stream as string) ?? null, message: (payload.chunk as string) ?? (payload.message as string) ?? null, payload: payload, }); } else if (event.type === "heartbeat.run.status") { const status = payload.status as string; if (TERMINAL_STATUSES.has(status)) { notifyWorker("agents.sessions.event", { sessionId: params.sessionId, runId: run.id, seq: 0, eventType: status === "succeeded" ? "done" : "error", stream: "system", message: status === "succeeded" ? (typeof payload.finalText === "string" ? payload.finalText : null) : `Run ${status}`, payload: payload, }); cleanup(); } else { notifyWorker("agents.sessions.event", { sessionId: params.sessionId, runId: run.id, seq: 0, eventType: "status", stream: "system", message: `Run status: ${status}`, payload: payload, }); } } }); // Safety-net timeout: if the run never reaches a terminal status, // force-cleanup the subscription to prevent unbounded leaks. const timeoutTimer = setTimeout(() => { logger.warn( { pluginId, pluginKey, runId: run.id }, "session event subscription timed out — forcing cleanup", ); cleanup(); }, SESSION_EVENT_SUBSCRIPTION_TIMEOUT_MS); const entry = { unsubscribe, timer: timeoutTimer }; activeSubscriptions.add(entry); } return { runId: run.id }; }, async close(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const deleted = await db .delete(agentTaskSessionsTable) .where( and( eq(agentTaskSessionsTable.id, params.sessionId), eq(agentTaskSessionsTable.companyId, companyId), like(agentTaskSessionsTable.taskKey, `plugin:${pluginKey}:session:%`), ), ) .returning() .then((rows) => rows.length); if (deleted === 0) throw new Error(`Session not found: ${params.sessionId}`); }, }, /** * Clean up all active session event subscriptions and flush any buffered * log entries. Must be called when the plugin worker is stopped, crashed, * or unloaded to prevent leaked listeners and lost log entries. */ dispose() { disposed = true; // Clear event bus subscriptions to prevent accumulation on worker restart. // Without this, each crash/restart cycle adds duplicate subscriptions. scopedBus.clear(); // Snapshot to avoid iterator invalidation from concurrent sendMessage() calls const snapshot = Array.from(activeSubscriptions); activeSubscriptions.clear(); for (const entry of snapshot) { clearTimeout(entry.timer); entry.unsubscribe(); } // Flush any buffered log entries synchronously-as-possible on dispose. flushPluginLogBuffer().catch((err) => { console.error("[plugin-host-services] dispose() log flush failed:", err); }); }, }; }