mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Agent creation, approval, pause, and termination change whether an agent can work. > - These changes currently use separate service and database paths. > - Some agents need setup after their record exists and before their harness can run. > - This PR adds one module that owns those state changes and their transactions. > - The module records progress and retries incomplete work after a server restart. ## Linked Issues or Issue Description **Problem or motivation** A hire can become active before its execution environment is ready. Direct agent state writes also bypass one common lifecycle boundary. Termination does not have a durable step for resource cleanup. **Proposed solution** Use agent lifecycle commands for hiring, approval, pause, resume, and termination. Keep proposed hires pending until approval. Prepare each approved hire, test its saved harness configuration, and then permit work. Wait for required plugin work before completing a step. **Alternatives considered** A general database commit hook would expose transaction details to callers. A timer alone would delay each new hire. This module owns each command transaction and starts its worker after commit. A periodic scan recovers work after a process stops. **Roadmap alignment** This change adds the lifecycle boundary for the existing agent and plugin systems. It does not add a user interface or a provider implementation. It replaces the lifecycle delivery approach in #15391. Related proposals include #11475 and #13099; this change does not add their external executor protocols. ## What Changed - Add durable approval, preparation, verification, pause, resume, stop, and cleanup states. - Keep agent creation, lifecycle state, configuration writes, and agent record deletion behind `modules/agent-lifecycle`. Keep validation, credential rules, reads, onboarding, invitations, general approvals, and company data deletion in their services. - Commit a hire before the remaining onboarding or invitation work. Serialize requests with the existing dedicated database connection helper. Reuse the hire after a failed attempt. - Keep accepted work in the run queue during setup. Require execution and cleanup evidence before completing pause or termination. - Retain the original credential owner when approving migrated hires. - Start lifecycle work after each command commits. Use database leases and revision checks for retries and late results. - Add a generic required plugin method and capability. Store the required plugin IDs for each agent. Track host and plugin completion separately. - Keep harness verification and credential rules in services. Supply their results to lifecycle through explicit ports. Reject failed logins and incomplete probes. - Remove reverse imports from lifecycle adapters into services. Share record queries and validation below both layers. - Use one hire decision path for agent IDs and approval IDs. Resolve the approval and agent change in one transaction. Expose policy reconciliation on the command object. - Save configuration and verification invalidation through one module operation. Ordinary commands own their transaction. Restrict the shared transaction integration to its listed callers. Reject identity, company, accounting, timestamp, and unknown fields. - Require completed termination before deleting agent records. The company deletion service calls the module through a separate deletion interface. - Save an approved secret before applying its agent binding. A failed binding leaves the secret approved. A retry reuses it. - Remove old permission and metadata export files. Move hire configuration rules out of lifecycle persistence. - Report failed worker steps and scans with operation context and known error codes. Omit provider messages, stacks, and configuration values. Document ordinary application use and extension rules. - Recover expired native owners only after execution stops. Retry cleanup of failed retained leases. Wait for setup in the live smoke script. - Keep manual, budget, and company pause reasons separate. Guard execution status writes with the ready state. - Add a migration, boundary checks, regression tests, and a lifecycle guide. Keep the current UI. - Limit periodic policy checks to 100 eligible agents per minute. Continue from the last agent ID. Exclude pending hires and agents in termination or a final state. Keep explicit company and budget updates immediate. ## Verification - Latest head `ccc3c86b0` aligns the shared status update schema with the PATCH route. All 15 shared tests, 104 server route/OpenAPI tests, 2 CLI tests, and shared/server/CLI TypeScript checks pass. The Greptile comment is fixed. CI is running; no new Apex review was requested for this small fix. - Rebase `75708ac94` rebases onto master at `2eb5187e4`. The backup test uses the shared 90-second timeout from master; the older timeout commit was removed. The affected backup test passes. - Agent budget edits now pass the target agent ID through enforcement. All 50 affected tests, server TypeScript, and 11 boundary tests pass. Both configuration update paths have regression coverage. CI is running on the latest head. No new Apex review was requested for these small fixes. - Rebase `9b568666a` preserves the run-control extraction and delivery-work startup changes from master. All 559 affected tests, server TypeScript, and 11 boundary tests pass. CI and Apex review are running on the final head. - The policy scan fix passes 56 lifecycle and diagnostic tests, 24 policy and caller tests, and 11 boundary tests. A regression test checks the page limit, interval, excluded states, and cursor wraparound. - Rebase `fd4698750` passes all 611 tests in eight affected lifecycle, route, and heartbeat suites. Server TypeScript and all 11 module boundary tests pass. The company deletion rollback and concurrent hire tests are included. - Follow-up `00aac7646` fixes the failed decision deletion test and the public update schema. All 126 affected server tests, 8 shared tests, 2 CLI tests, and 11 boundary tests pass. Shared, server, CLI, and UI TypeScript checks pass. - Server TypeScript passes: `pnpm --filter @paperclipai/server exec tsc --noEmit`. - All 11 boundary tests pass: `node --test scripts/check-module-boundaries.test.mjs`. The repository boundary scan also passes. - All 68 lifecycle, failure diagnostic, and harness verification tests pass. Tests reject forbidden fields through the root command, combined transition command, and restricted transaction integration. They also check invalid fields returned by configuration preparation. - Failure tests cover host work, plugin work, claims, renewals, deferrals, and scans. Log tests check known error codes and exclusion of private error contents. - All 355 tests in 10 existing caller suites pass. These tests cover secret approval, pairing, pending hires, credentials, company import, built-in agents, managed agents, instructions, and skills. - The branch is rebased onto `origin/master` at `2eb5187e4`. The queue guards and cancellation lock fix use the extracted heartbeat queue. Agent cancellation uses the extracted run-control file. The source diff scan found no secrets or private URLs. - Earlier checks cover approval retries, rollback, late verification results, policy holds, deletion rollback, concurrent company deletion, and queue admission during setup. - Before the rebase, CI passed on `00aac7646` and Greptile Apex rated it 5/5 with no open comments. CI and a new Apex review are pending for the rebased commit. The branch has no merge conflicts. - The full local build and native Dot suite were not run. The host has no Cargo. No live provider test was run. ## Risks - Background policy recovery takes more than one minute when more than 100 agents are eligible. Explicit company and budget updates still reconcile immediately. - The restricted configuration integration keeps an agent reference and its dependent records in one transaction. It cannot change a budget or invoke a lifecycle transition. A periodic scan starts pending setup after that transaction commits. - Secret approval and binding use separate transactions. A failed binding can leave an approved secret with no agent binding. - New approved hires remain unavailable until setup and verification complete. Existing clients see `paused` during these steps. This PR does not change the UI. - Migration `0320_daily_onslaught.sql` maps existing statuses and pause reasons. It does not create external resources or claim that an existing harness passed verification. - Use a coordinated server update for this migration. Older server versions can write legacy status without the new lifecycle checks. - A failed onboarding or invitation request can leave a committed hire. A retry reuses that hire and completes the remaining records. - Deletion requires completed termination. Company deletion can require another request while cleanup is pending. - Required plugins must make repeated calls safe and reject older revisions. A disabled or removed required plugin blocks completion until it is restored. - A new hire can run a second harness test after the existing UI test. Some adapters can charge for this test. - No live provider test was run. Provider implementation and resource backfill remain separate work. ## Model Used OpenAI GPT-6 through Codex, with reasoning, code tools, and local test execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have described the issue in this PR with the feature request field labels - [x] I have not referenced internal or instance-local issues or links - [x] My branch name describes the change - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation - [x] I have considered and documented risks - [x] Targeted local tests pass - [x] All Paperclip CI gates are green on the new commit - [x] Greptile Apex is 5/5 with no open comments on the new commit - [x] I will address review comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
3656 lines
147 KiB
TypeScript
3656 lines
147 KiB
TypeScript
import { createAgentLifecycle } from "./agent-lifecycle.js";
|
|
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, IssueRelationIssueSummary, 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 { pluginLifecycleInbox } from "./plugin-lifecycle-inbox.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,
|
|
canActorReadIssuePrivacy,
|
|
issuePrivacyMode,
|
|
issueReadSqlCondition,
|
|
type AuthorizationActor,
|
|
type IssuePrivacyRow,
|
|
} 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<ValidatedFetchTarget> {
|
|
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<never>((_, 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<string, string>; body: string }> {
|
|
const { options, body } = buildPinnedRequestOptions(target, init);
|
|
|
|
const response = await new Promise<IncomingMessage>((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<void>((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<string, string> = {};
|
|
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<string, unknown> | null | undefined): Record<string, unknown> | null {
|
|
if (meta == null) return null;
|
|
// Strip pino reserved keys
|
|
const cleaned: Record<string, unknown> = {};
|
|
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<string, unknown> | 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<void> {
|
|
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<Db, BufferedLogEntry[]>();
|
|
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<string> = 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.<name>`; 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<string> = new Set<string>([
|
|
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<string> = new Set<string>([
|
|
SPAN_ATTRS.packWallMs,
|
|
SPAN_ATTRS.transferWallMs,
|
|
SPAN_ATTRS.transferGuardCount,
|
|
]);
|
|
|
|
/** The closed value set for the `outcome` attribute. */
|
|
const KNOWN_SPAN_OUTCOMES: ReadonlySet<string> = new Set(["ok", "skipped", "failed"]);
|
|
|
|
/** The closed value set for the `transfer.direction` attribute. */
|
|
const KNOWN_TRANSFER_DIRECTIONS: ReadonlySet<string> = 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<string, unknown> | undefined,
|
|
): Record<string, string | number | boolean> {
|
|
const clamped: Record<string, string | number | boolean> = {};
|
|
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<string, unknown>;
|
|
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<string, string | undefined>;
|
|
} = {},
|
|
): 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 agentsLifecycle = createAgentLifecycle(db);
|
|
const managedAgents = pluginManagedAgentService(db, {
|
|
pluginId,
|
|
pluginKey,
|
|
manifest: options.manifest,
|
|
instructionTemplateVariables: async (companyId) => {
|
|
const variables: Record<string, string | null | undefined> = {};
|
|
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);
|
|
const lifecycleInbox = pluginLifecycleInbox(db, pluginId);
|
|
|
|
// --- Issue privacy for plugin-facing reads (PAP-16091 / PAP-16050) ---------
|
|
// Plugins are non-member principals: they hold no company membership, issue
|
|
// access grant, or private-project access, so the canonical issue-visibility
|
|
// predicate resolves them to the same scope as any other non-member — public
|
|
// issues only. Notification/digest plugins (e.g. a Slack notifier) fan issue
|
|
// data out to shared channels whose audience is, by definition, non-members,
|
|
// so private issue content and relationship metadata must never reach them.
|
|
//
|
|
// A synthetic { type: "none" } actor produces exactly that scope: it yields a
|
|
// null privacy principal (no implicit/grant/project-member match) and does not
|
|
// trip the local-implicit / instance-admin bypasses inside the predicate. In
|
|
// "off"/"shadow" modes these helpers are pass-through, matching every other
|
|
// enforcement surface so the enforce flip stays the single behavioral switch.
|
|
const pluginIssuePrivacyActor: AuthorizationActor = { type: "none" };
|
|
const toIssuePrivacyRow = (issue: {
|
|
id: string;
|
|
companyId: string;
|
|
visibility?: string | null;
|
|
privacyRootIssueId?: string | null;
|
|
responsibleUserId?: string | null;
|
|
createdByUserId?: string | null;
|
|
assigneeUserId?: string | null;
|
|
assigneeAgentId?: string | null;
|
|
projectId?: string | null;
|
|
}): IssuePrivacyRow => ({
|
|
id: issue.id,
|
|
companyId: issue.companyId,
|
|
visibility: issue.visibility ?? "open",
|
|
privacyRootIssueId: issue.privacyRootIssueId ?? null,
|
|
responsibleUserId: issue.responsibleUserId ?? null,
|
|
createdByUserId: issue.createdByUserId ?? null,
|
|
assigneeUserId: issue.assigneeUserId ?? null,
|
|
assigneeAgentId: issue.assigneeAgentId ?? null,
|
|
projectId: issue.projectId ?? null,
|
|
});
|
|
const pluginCanReadIssue = async (
|
|
issue: Parameters<typeof toIssuePrivacyRow>[0] | null | undefined,
|
|
): Promise<boolean> => {
|
|
if (!issue) return false;
|
|
if (issuePrivacyMode() !== "enforce") return true;
|
|
return canActorReadIssuePrivacy(db, pluginIssuePrivacyActor, toIssuePrivacyRow(issue));
|
|
};
|
|
const pluginReadableIssueIds = async (
|
|
issueList: Array<Parameters<typeof toIssuePrivacyRow>[0]>,
|
|
): Promise<Set<string>> => {
|
|
if (issuePrivacyMode() !== "enforce") return new Set(issueList.map((issue) => issue.id));
|
|
const decisions = await Promise.all(
|
|
issueList.map(async (issue) => ({
|
|
id: issue.id,
|
|
allowed: await canActorReadIssuePrivacy(db, pluginIssuePrivacyActor, toIssuePrivacyRow(issue)),
|
|
})),
|
|
);
|
|
return new Set(decisions.filter((decision) => decision.allowed).map((decision) => decision.id));
|
|
};
|
|
// Column projection matching IssuePrivacyRow, for ad-hoc readability lookups
|
|
// of bare issue IDs (relation targets, subtree descendants) that arrive
|
|
// without their privacy columns loaded.
|
|
const issuePrivacyRowColumns = {
|
|
id: issuesTable.id,
|
|
companyId: issuesTable.companyId,
|
|
visibility: issuesTable.visibility,
|
|
privacyRootIssueId: issuesTable.privacyRootIssueId,
|
|
responsibleUserId: issuesTable.responsibleUserId,
|
|
createdByUserId: issuesTable.createdByUserId,
|
|
assigneeUserId: issuesTable.assigneeUserId,
|
|
assigneeAgentId: issuesTable.assigneeAgentId,
|
|
projectId: issuesTable.projectId,
|
|
} as const;
|
|
// Resolve which of the given issue IDs a non-member plugin may read. Loads
|
|
// the privacy-relevant columns for those IDs and evaluates the canonical
|
|
// predicate. An ID with no surviving row (deleted, cross-company) is absent
|
|
// from the result set — fail closed.
|
|
const pluginReadableIssueIdsByIds = async (
|
|
companyId: string,
|
|
ids: Iterable<string>,
|
|
): Promise<Set<string>> => {
|
|
const uniqueIds = [...new Set(ids)];
|
|
if (issuePrivacyMode() !== "enforce" || uniqueIds.length === 0) {
|
|
return new Set(uniqueIds);
|
|
}
|
|
const rows = await db
|
|
.select(issuePrivacyRowColumns)
|
|
.from(issuesTable)
|
|
.where(and(eq(issuesTable.companyId, companyId), inArray(issuesTable.id, uniqueIds)));
|
|
return pluginReadableIssueIds(rows);
|
|
};
|
|
// Fetch an issue and require it be both in-company and readable by a
|
|
// non-member plugin. A private (unreadable) issue throws the same "not found"
|
|
// error a genuinely missing issue would, so its existence never leaks. This
|
|
// is the single gate every issue-derived plugin read routes through, so no
|
|
// method needs its own bespoke visibility check.
|
|
const requirePluginReadableIssue = async (issueId: string, companyId: string): Promise<Issue> => {
|
|
const issue = requireInCompany("Issue", await issues.getById(issueId), companyId);
|
|
if (!(await pluginCanReadIssue(issue))) {
|
|
throw new Error("Issue not found");
|
|
}
|
|
return issue as Issue;
|
|
};
|
|
// Redact a relation summary so blocker/blocked edges pointing at an issue the
|
|
// plugin cannot read are dropped, recursing into nested terminal blockers. An
|
|
// edge's title/status/assignee is relationship metadata a non-member must not
|
|
// see even when the issue that owns the edge is itself public.
|
|
const pluginRedactRelationSummary = async <
|
|
R extends { blockedBy: IssueRelationIssueSummary[]; blocks: IssueRelationIssueSummary[] },
|
|
>(
|
|
companyId: string,
|
|
relation: R,
|
|
): Promise<R> => {
|
|
if (issuePrivacyMode() !== "enforce") return relation;
|
|
const edgeIds = new Set<string>();
|
|
const collect = (edges: IssueRelationIssueSummary[]) => {
|
|
for (const edge of edges) {
|
|
edgeIds.add(edge.id);
|
|
if (edge.terminalBlockers?.length) collect(edge.terminalBlockers);
|
|
}
|
|
};
|
|
collect(relation.blockedBy);
|
|
collect(relation.blocks);
|
|
if (edgeIds.size === 0) return relation;
|
|
const readable = await pluginReadableIssueIdsByIds(companyId, edgeIds);
|
|
const redact = (edges: IssueRelationIssueSummary[]): IssueRelationIssueSummary[] =>
|
|
edges
|
|
.filter((edge) => readable.has(edge.id))
|
|
.map((edge) => (edge.terminalBlockers?.length
|
|
? { ...edge, terminalBlockers: redact(edge.terminalBlockers) }
|
|
: edge));
|
|
return { ...relation, blockedBy: redact(relation.blockedBy), blocks: redact(relation.blocks) };
|
|
};
|
|
|
|
// Track active session event subscriptions for cleanup
|
|
const activeSubscriptions = new Set<{ unsubscribe: () => void; timer: ReturnType<typeof setTimeout> }>();
|
|
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 = <T>(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<typeof condition> => 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 = <T extends { companyId: string | null | undefined }>(
|
|
record: T | null | undefined,
|
|
companyId: string,
|
|
): record is T => Boolean(record && record.companyId === companyId);
|
|
|
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
|
|
const readProviderMetadata = (metadata: Record<string, unknown> | 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<Awaited<ReturnType<typeof executionWorkspaces.getById>>>,
|
|
): 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 = <T extends { companyId: string | null | undefined }>(
|
|
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<void> => {
|
|
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<string, unknown> | 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 = <T extends { payload: Record<string, unknown> }>(approval: T): T => ({
|
|
...approval,
|
|
payload: redactEventPayload(approval.payload) ?? {},
|
|
});
|
|
|
|
const pluginActivityDetails = (
|
|
details: Record<string, unknown> | 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<string, unknown> | 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<string>([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<string | null>`${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<typeof condition> => 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<number>`coalesce(sum(${costEvents.costCents}), 0)::double precision`,
|
|
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::double precision`,
|
|
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::double precision`,
|
|
outputTokens: sql<number>`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<string, unknown> | 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<string, unknown> : {}),
|
|
role: humanRole,
|
|
};
|
|
}
|
|
if (agentMessage) {
|
|
defaults.agent = {
|
|
...(typeof defaults.agent === "object" && defaults.agent !== null ? defaults.agent as Record<string, unknown> : {}),
|
|
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<string, unknown> : {};
|
|
return {
|
|
resourceType,
|
|
resourceId,
|
|
companyId,
|
|
policy: permissions.authorizationPolicy && typeof permissions.authorizationPolicy === "object"
|
|
? sanitizeRecord(permissions.authorizationPolicy as Record<string, unknown>)
|
|
: 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<string, unknown>).authorizationPolicy
|
|
: null;
|
|
return {
|
|
resourceType,
|
|
resourceId,
|
|
companyId,
|
|
policy: policy && typeof policy === "object" ? sanitizeRecord(policy as Record<string, unknown>) : 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<string, unknown>).authorizationPolicy
|
|
: null;
|
|
return {
|
|
resourceType,
|
|
resourceId,
|
|
companyId,
|
|
policy: policy && typeof policy === "object" ? sanitizeRecord(policy as Record<string, unknown>) : 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 listLifecycle(params) {
|
|
return lifecycleInbox.list(ensureCompanyId(params.companyId), params.limit, params.afterId);
|
|
},
|
|
async acknowledgeLifecycle(params) {
|
|
await lifecycleInbox.acknowledge(ensureCompanyId(params.companyId), params.eventId);
|
|
},
|
|
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<string, unknown> | 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<string, unknown>).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);
|
|
// Push the non-member visibility predicate into the list query so private
|
|
// issues are filtered at the SQL layer before they can reach a plugin.
|
|
const readCondition = await issueReadSqlCondition(db, pluginIssuePrivacyActor);
|
|
return applyWindow(
|
|
(await issues.list(companyId, { ...(params as any), readCondition })) as Issue[],
|
|
params,
|
|
);
|
|
},
|
|
async get(params) {
|
|
const companyId = ensureCompanyId(params.companyId);
|
|
await ensurePluginAvailableForCompany(companyId);
|
|
const issue = await issues.getById(params.issueId);
|
|
if (!inCompany(issue, companyId)) return null;
|
|
if (!(await pluginCanReadIssue(issue))) return null;
|
|
return issue as Issue;
|
|
},
|
|
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,
|
|
);
|
|
if (issueInput.parentId) await requirePluginReadableIssue(issueInput.parentId, companyId);
|
|
const sourceRunId = params.originRunId ?? actorRunId;
|
|
if (sourceRunId) {
|
|
const [sourceRun] = await db.select().from(heartbeatRuns)
|
|
.where(and(eq(heartbeatRuns.id, sourceRunId), eq(heartbeatRuns.companyId, companyId)));
|
|
if (!sourceRun) throw new Error("Origin run not found");
|
|
if (sourceRun.scopeKind !== "company") {
|
|
if (!sourceRun.issueId) throw new Error("Origin run not found");
|
|
await requirePluginReadableIssue(sourceRun.issueId, companyId);
|
|
}
|
|
}
|
|
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);
|
|
await requirePluginReadableIssue(params.issueId, companyId);
|
|
const patch = { ...(params.patch as Record<string, unknown>) };
|
|
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);
|
|
// Private root -> indistinguishable from missing; readable root ->
|
|
// still redact any edge that points at an issue the plugin cannot read.
|
|
await requirePluginReadableIssue(params.issueId, companyId);
|
|
return await pluginRedactRelationSummary(
|
|
companyId,
|
|
await issues.getRelationSummaries(params.issueId),
|
|
);
|
|
},
|
|
async setBlockedBy(params) {
|
|
const companyId = ensureCompanyId(params.companyId);
|
|
await ensurePluginAvailableForCompany(companyId);
|
|
// The echoed relation summary can name a private blocker target, so
|
|
// redact unreadable edges before returning it to a non-member plugin.
|
|
return pluginRedactRelationSummary(companyId, await 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 pluginRedactRelationSummary(companyId, await 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 pluginRedactRelationSummary(companyId, await 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);
|
|
// A private root is invisible to a non-member plugin: report not-found
|
|
// rather than leak its existence or any descendant metadata.
|
|
const rootIssue = await requirePluginReadableIssue(params.issueId, companyId);
|
|
const includeRoot = params.includeRoot !== false;
|
|
const subtreeIssueIds = await collectIssueSubtreeIds(companyId, rootIssue.id);
|
|
const candidateIssueIds = includeRoot
|
|
? subtreeIssueIds
|
|
: subtreeIssueIds.filter((issueId) => issueId !== rootIssue.id);
|
|
const issueRows = candidateIssueIds.length > 0
|
|
? await db
|
|
.select()
|
|
.from(issuesTable)
|
|
.where(and(eq(issuesTable.companyId, companyId), inArray(issuesTable.id, candidateIssueIds)))
|
|
: [];
|
|
// Filter private descendants BEFORE fetching any relations, documents,
|
|
// runs, or assignees so private issue rows and their metadata never
|
|
// enter the result. The root has already cleared the readability gate.
|
|
const readableSubtreeIds = await pluginReadableIssueIds(issueRows);
|
|
readableSubtreeIds.add(rootIssue.id);
|
|
const issueIds = candidateIssueIds.filter((issueId) => readableSubtreeIds.has(issueId));
|
|
const issuesById = new Map(
|
|
issueRows
|
|
.filter((issue) => readableSubtreeIds.has(issue.id))
|
|
.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 pluginRedactRelationSummary(companyId, 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<PluginIssueOrchestrationSummary> {
|
|
const companyId = ensureCompanyId(params.companyId);
|
|
await ensurePluginAvailableForCompany(companyId);
|
|
// A private root is invisible to a non-member plugin: report not-found
|
|
// rather than leak its existence or any subtree relationship metadata.
|
|
const rootIssue = await requirePluginReadableIssue(params.issueId, companyId);
|
|
const rawSubtreeIssueIds = params.includeSubtree
|
|
? await collectIssueSubtreeIds(companyId, rootIssue.id)
|
|
: [rootIssue.id];
|
|
// Drop any private descendant the plugin may not read so its relations,
|
|
// approvals, runs, costs, and budget blocks never enter the summary.
|
|
const readableSubtreeIds = await pluginReadableIssueIdsByIds(companyId, rawSubtreeIssueIds);
|
|
readableSubtreeIds.add(rootIssue.id);
|
|
const subtreeIssueIds = rawSubtreeIssueIds.filter((id) => readableSubtreeIds.has(id));
|
|
// Blocker edges can point at private issues outside the readable subtree
|
|
// (e.g. a public task blocked by a private one). Their title/status/
|
|
// assignee are relationship metadata a non-member must not see, so
|
|
// pluginRedactRelationSummary drops any edge whose target is unreadable,
|
|
// recursing into nested terminal blockers.
|
|
const relationPairs = await Promise.all(
|
|
subtreeIssueIds.map(async (issueId) => [
|
|
issueId,
|
|
await pluginRedactRelationSummary(companyId, 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<typeof block> => 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);
|
|
const issue = await issues.getById(params.issueId);
|
|
if (!inCompany(issue, companyId)) return [];
|
|
// Comment visibility follows the ACL of the issue they were posted on.
|
|
if (!(await pluginCanReadIssue(issue))) 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);
|
|
const issue = await issues.getById(params.issueId);
|
|
if (!inCompany(issue, companyId)) return [];
|
|
// Interaction payloads follow the ACL of the issue they belong to; a
|
|
// private issue yields no interactions to a non-member plugin.
|
|
if (!(await pluginCanReadIssue(issue))) 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);
|
|
const issue = await issues.getById(params.issueId);
|
|
if (!inCompany(issue, companyId)) return [];
|
|
if (!(await pluginCanReadIssue(issue))) 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;
|
|
// A private issue's attachment content must not reach a non-member
|
|
// plugin: gate on the parent issue's visibility, same null response.
|
|
if (!(await pluginCanReadIssue(await issues.getById(attachment.issueId)))) 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);
|
|
// Document bodies follow the issue ACL; a private issue is not-found.
|
|
await requirePluginReadableIssue(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);
|
|
await requirePluginReadableIssue(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 agentsLifecycle.pauseAgent(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 agentsLifecycle.resumeAgent(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<string, typeof grants>();
|
|
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<string, unknown>),
|
|
},
|
|
});
|
|
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<typeof condition> => 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<string, unknown>) }
|
|
: {};
|
|
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<string, unknown>) }
|
|
: {};
|
|
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<string, unknown>) }
|
|
: {};
|
|
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<typeof condition> => 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<string, unknown> | 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);
|
|
});
|
|
},
|
|
};
|
|
}
|