Files
PaperClipAI/server/src/__tests__/plugin-environment-driver-seam.test.ts
T
DottaandPaperclip 2dbaf4a7fa External object references across issue surfaces (#8512)
## Thinking Path

> - Paperclip is the open source control plane people use to coordinate
AI agents, issues, approvals, comments, and work products.
> - The involved subsystem is issue context: markdown links, issue
properties, related work, lists, filters, inbox/sidebar status, and
plugin-provided external context.
> - The gap is that URLs to external systems currently remain mostly
plain links, so humans and agents must manually open them to understand
status, identity, and liveness.
> - This matters because external work objects such as GitHub issues and
pull requests are part of the operational state of a Paperclip company.
> - The implementation keeps core provider-neutral: shared contracts,
storage, sync, routes, and UI surfaces live in core while providers can
contribute detection and status resolution.
> - This pull request adds the external object reference foundation,
GitHub provider support, issue-surface rendering, filters,
sidebar/list/inbox signals, and test/story coverage.
> - The benefit is that linked external work becomes inspectable
Paperclip context without hardcoding every provider directly into the
UI.

## Linked Issues or Issue Description

No public GitHub issue exists for this work.

Feature request:

- Problem: URLs in Paperclip issues, comments, documents, and related
surfaces do not expose provider status or object identity inline.
- Proposed behavior: detect supported external object URLs, persist
normalized references, refresh provider status, and render concise
status-aware links across issue surfaces.
- Users affected: board users, agents, and maintainers who triage issues
containing external work links.
- Acceptance: external object references are company-scoped,
provider-extensible, visible in key issue surfaces, filterable where
relevant, and covered by focused shared/server/UI tests.

Related PR search:

- No open duplicate PRs found for `external object references`.
- Closed related prior attempt: #4556.

## What Changed

- Added shared external-object contracts, validators, status/liveness
helpers, and plugin protocol declarations.
- Added database schema and additive migrations for external objects,
source mentions, and display metadata.
- Added server services/routes for detecting, syncing, summarizing,
refreshing, and resolving external objects across issues, documents,
comments, projects, and plugins.
- Added a GitHub external-object provider plus plugin SDK authoring
docs.
- Wired UI presentation across markdown links, comments, issue chat,
documents, properties, related work, issue rows, filters, inbox/sidebar
badges, and Storybook stories.
- Rebasing cleanup: moved the branch onto current `master`, repaired
stale worktree provision config, hardened environment-sensitive
tests/mocks, and removed committed screenshot artifacts from the PR
branch to keep the reviewable file set below tool limits.

## Verification

- `pnpm exec vitest run packages/shared/src/external-objects.test.ts
server/src/__tests__/external-object-routes.test.ts
server/src/__tests__/external-objects-service.test.ts
ui/src/components/ExternalObjectPill.test.tsx
ui/src/lib/external-objects.test.ts` passed after rebasing: 5 files, 56
tests.
- Historical branch verification before this PR creation included `pnpm
test:run`, `pnpm -r typecheck`, and `pnpm build`; this PR body does not
claim those were rerun after the final rebase.

## Risks

- Medium: this adds a new cross-surface sync path on
issue/document/comment writes. The implementation uses safe sync
wrappers so external-object failures warn instead of blocking core
mutations.
- Medium: the migrations introduce new tables and indexes. They are
additive and company-scoped.
- Medium: provider-specific URL parsing can miss or misclassify edge
cases. Shared canonicalization tests and provider tests cover current
GitHub shapes.
- Low: UI badge/filter behavior could add visual noise for object-heavy
issues; component tests and Storybook stories cover the intended
surfaces.

> Roadmap checked: `ROADMAP.md` references the plugin system as the
current extension path and does not list a duplicate core feature.
Related long-range docs discuss external references, work products,
preview URLs, and plugin extension points; this PR implements the scoped
external-object reference foundation.

## Model Used

OpenAI Codex, GPT-5 coding-agent runtime, with shell and GitHub CLI tool
use. Reasoning mode: medium. Exact deployed runtime model ID and context
window were not exposed in the environment.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-06-23 08:27:19 -05:00

366 lines
12 KiB
TypeScript

import { PassThrough } from "node:stream";
import { describe, expect, it } from "vitest";
import {
PLUGIN_RPC_ERROR_CODES,
createRequest,
isJsonRpcErrorResponse,
isJsonRpcSuccessResponse,
parseMessage,
serializeMessage,
} from "../../../packages/plugins/sdk/src/protocol.js";
import { definePlugin } from "../../../packages/plugins/sdk/src/define-plugin.js";
import { startWorkerRpcHost } from "../../../packages/plugins/sdk/src/worker-rpc-host.js";
import { pluginManifestV1Schema, type PaperclipPluginManifestV1 } from "@paperclipai/shared";
import { pluginCapabilityValidator } from "../services/plugin-capability-validator.js";
const baseManifest: PaperclipPluginManifestV1 = {
id: "test.environment-driver",
apiVersion: 1,
version: "1.0.0",
displayName: "Environment Driver",
description: "Test environment driver plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: { worker: "dist/worker.js" },
environmentDrivers: [
{
driverKey: "fake-plugin",
displayName: "Fake plugin",
configSchema: {
type: "object",
properties: {
template: { type: "string" },
},
required: ["template"],
},
},
],
};
describe("plugin environment driver seam", () => {
it("validates environment driver manifest declarations", () => {
expect(pluginManifestV1Schema.safeParse(baseManifest).success).toBe(true);
const missingCapability = pluginManifestV1Schema.safeParse({
...baseManifest,
capabilities: ["http.outbound"],
});
expect(missingCapability.success).toBe(false);
expect(JSON.stringify(missingCapability.error?.issues)).toContain(
"environment.drivers.register",
);
const duplicateDriver = pluginManifestV1Schema.safeParse({
...baseManifest,
environmentDrivers: [
baseManifest.environmentDrivers![0],
{ ...baseManifest.environmentDrivers![0], displayName: "Duplicate" },
],
});
expect(duplicateDriver.success).toBe(false);
expect(JSON.stringify(duplicateDriver.error?.issues)).toContain(
"Duplicate environment driver keys",
);
});
it("enforces environment driver capability requirements", () => {
const validator = pluginCapabilityValidator();
expect(validator.getRequiredCapabilities("environment.acquireLease")).toEqual([
"environment.drivers.register",
]);
expect(validator.checkOperation(baseManifest, "environment.execute").allowed).toBe(true);
const withoutCapability = {
...baseManifest,
capabilities: ["http.outbound"],
} satisfies PaperclipPluginManifestV1;
expect(validator.checkOperation(withoutCapability, "environment.execute")).toMatchObject({
allowed: false,
missing: ["environment.drivers.register"],
});
expect(validator.validateManifestCapabilities(withoutCapability)).toMatchObject({
allowed: false,
missing: ["environment.drivers.register"],
});
});
it("dispatches environment driver worker hooks and reports support", async () => {
const plugin = definePlugin({
async setup() {},
async onEnvironmentProbe(params) {
return {
ok: true,
summary: `probed ${params.driverKey}`,
metadata: { environmentId: params.environmentId },
};
},
});
const stdin = new PassThrough();
const stdout = new PassThrough();
const host = startWorkerRpcHost({ plugin, stdin, stdout });
const responses: unknown[] = [];
stdout.on("data", (chunk) => {
const lines = String(chunk).split("\n").filter(Boolean);
for (const line of lines) {
responses.push(parseMessage(line));
}
});
stdin.write(serializeMessage(createRequest("initialize", {
manifest: baseManifest,
config: {},
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
apiVersion: 1,
}, 1)));
await waitForResponses(responses, 1);
const initializeResponse = responses[0];
expect(isJsonRpcSuccessResponse(initializeResponse)).toBe(true);
if (!isJsonRpcSuccessResponse(initializeResponse)) return;
expect(initializeResponse.result.supportedMethods).toContain("environmentProbe");
stdin.write(serializeMessage(createRequest("environmentProbe", {
driverKey: "fake-plugin",
companyId: "company-1",
environmentId: "environment-1",
config: { template: "base" },
}, 2)));
await waitForResponses(responses, 2);
const probeResponse = responses[1];
expect(isJsonRpcSuccessResponse(probeResponse)).toBe(true);
if (!isJsonRpcSuccessResponse(probeResponse)) return;
expect(probeResponse.result).toMatchObject({
ok: true,
summary: "probed fake-plugin",
metadata: { environmentId: "environment-1" },
});
stdin.write(serializeMessage(createRequest("environmentExecute", {
driverKey: "fake-plugin",
companyId: "company-1",
environmentId: "environment-1",
config: { template: "base" },
lease: { providerLeaseId: "lease-1" },
command: "echo",
}, 3)));
await waitForResponses(responses, 3);
const executeResponse = responses[2];
expect(isJsonRpcErrorResponse(executeResponse)).toBe(true);
if (!isJsonRpcErrorResponse(executeResponse)) return;
expect(executeResponse.error.code).toBe(PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED);
expect(executeResponse.error.message).toContain("environmentExecute");
host.stop();
});
});
const objectReferenceManifest: PaperclipPluginManifestV1 = {
id: "test.external-object-provider",
apiVersion: 1,
version: "1.0.0",
displayName: "External Object Provider",
description: "Test external object provider plugin",
author: "Paperclip",
categories: ["connector"],
capabilities: ["external.objects.detect", "external.objects.read"],
entrypoints: { worker: "dist/worker.js" },
objectReferences: [
{
providerKey: "mocktracker",
displayName: "Mock Tracker",
objectTypes: ["ticket"],
urlPatterns: ["https://mock.example/tickets/:id"],
refreshPolicy: { defaultTtlSeconds: 300, staleAfterSeconds: 1800 },
},
],
};
describe("plugin external object provider seam", () => {
it("validates provider manifest declarations and capabilities", () => {
expect(pluginManifestV1Schema.safeParse(objectReferenceManifest).success).toBe(true);
const missingCapability = pluginManifestV1Schema.safeParse({
...objectReferenceManifest,
capabilities: ["external.objects.detect"],
});
expect(missingCapability.success).toBe(false);
expect(JSON.stringify(missingCapability.error?.issues)).toContain("external.objects.read");
const duplicateProvider = pluginManifestV1Schema.safeParse({
...objectReferenceManifest,
objectReferences: [
objectReferenceManifest.objectReferences![0],
{ ...objectReferenceManifest.objectReferences![0], displayName: "Duplicate" },
],
});
expect(duplicateProvider.success).toBe(false);
expect(JSON.stringify(duplicateProvider.error?.issues)).toContain(
"Duplicate object reference provider keys",
);
});
it("enforces provider capability requirements", () => {
const validator = pluginCapabilityValidator();
expect(validator.getRequiredCapabilities("external.objects.detect")).toEqual([
"external.objects.detect",
]);
expect(validator.getRequiredCapabilities("external.objects.read")).toEqual([
"external.objects.read",
]);
expect(validator.checkOperation(objectReferenceManifest, "external.objects.read").allowed).toBe(true);
const withoutCapability = {
...objectReferenceManifest,
capabilities: ["external.objects.detect"],
} satisfies PaperclipPluginManifestV1;
expect(validator.checkOperation(withoutCapability, "external.objects.read")).toMatchObject({
allowed: false,
missing: ["external.objects.read"],
});
expect(validator.validateManifestCapabilities(withoutCapability)).toMatchObject({
allowed: false,
missing: ["external.objects.read"],
});
});
it("dispatches provider detection and resolution worker hooks", async () => {
const plugin = definePlugin({
async setup() {},
async onDetectExternalObjects(params) {
return {
detections: params.urls.map((url) => ({
urlIdentityHash: url.canonicalIdentityHash,
providerKey: "mocktracker",
objectType: "ticket",
externalId: "MOCK-123",
displayTitle: "Mock ticket",
confidence: "exact",
})),
};
},
async onResolveExternalObject(params) {
return {
ok: true,
snapshot: {
displayTitle: `Resolved ${params.externalId}`,
statusKey: "ready",
statusLabel: "Ready",
statusCategory: "succeeded",
statusTone: "success",
isTerminal: true,
ttlSeconds: 600,
},
};
},
});
const stdin = new PassThrough();
const stdout = new PassThrough();
const host = startWorkerRpcHost({ plugin, stdin, stdout });
const responses: unknown[] = [];
stdout.on("data", (chunk) => {
const lines = String(chunk).split("\n").filter(Boolean);
for (const line of lines) {
responses.push(parseMessage(line));
}
});
stdin.write(serializeMessage(createRequest("initialize", {
manifest: objectReferenceManifest,
config: {},
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
apiVersion: 1,
}, 1)));
await waitForResponses(responses, 1);
const initializeResponse = responses[0];
expect(isJsonRpcSuccessResponse(initializeResponse)).toBe(true);
if (!isJsonRpcSuccessResponse(initializeResponse)) return;
expect(initializeResponse.result.supportedMethods).toContain("detectExternalObjects");
expect(initializeResponse.result.supportedMethods).toContain("resolveExternalObject");
stdin.write(serializeMessage(createRequest("detectExternalObjects", {
companyId: "company-1",
urls: [{
sanitizedCanonicalUrl: "https://mock.example/tickets/123",
sanitizedDisplayUrl: "https://mock.example/tickets/123",
canonicalIdentityHash: "hash-123",
canonicalIdentity: { scheme: "https", host: "mock.example", path: "/tickets/123" },
redactedMatchedText: "https://mock.example/tickets/123",
}],
sourceContext: {
companyId: "company-1",
sourceIssueId: "issue-1",
sourceKind: "description",
sourceRecordId: null,
documentKey: null,
propertyKey: null,
},
}, 2)));
await waitForResponses(responses, 2);
const detectResponse = responses[1];
expect(isJsonRpcSuccessResponse(detectResponse)).toBe(true);
if (!isJsonRpcSuccessResponse(detectResponse)) return;
expect(detectResponse.result.detections[0]).toMatchObject({
providerKey: "mocktracker",
objectType: "ticket",
externalId: "MOCK-123",
});
stdin.write(serializeMessage(createRequest("resolveExternalObject", {
companyId: "company-1",
providerKey: "mocktracker",
objectType: "ticket",
externalId: "MOCK-123",
object: {
id: "object-1",
companyId: "company-1",
providerKey: "mocktracker",
objectType: "ticket",
externalId: "MOCK-123",
sanitizedCanonicalUrl: "https://mock.example/tickets/123",
canonicalIdentityHash: "hash-123",
displayTitle: "Mock ticket",
statusKey: null,
statusLabel: null,
statusCategory: "unknown",
statusTone: "neutral",
liveness: "unknown",
isTerminal: false,
data: {},
remoteVersion: null,
etag: null,
},
}, 3)));
await waitForResponses(responses, 3);
const resolveResponse = responses[2];
expect(isJsonRpcSuccessResponse(resolveResponse)).toBe(true);
if (!isJsonRpcSuccessResponse(resolveResponse)) return;
expect(resolveResponse.result).toMatchObject({
ok: true,
snapshot: {
statusCategory: "succeeded",
statusTone: "success",
},
});
host.stop();
});
});
async function waitForResponses(responses: unknown[], count: number): Promise<void> {
const deadline = Date.now() + 1_000;
while (responses.length < count && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
expect(responses.length).toBeGreaterThanOrEqual(count);
}