Files
PaperClipAI/packages/plugins/sdk/tests/worker-rpc-host.test.ts
T
Nicky LeachandPaperclip 1d571de5f9 Add shared native turn attribution boundaries (#18213)
## Thinking Path

> - Paperclip manages work performed by AI agents.
> - Native execution crosses the plugin worker, Runner, sidecar, and
durable event boundaries.
> - Existing outer spans leave repeated preparation and admission work
unattributed.
> - Optimizations need a common baseline with accurate ownership and
event meanings.
> - This change extends the existing opt-in tracing contract with
bounded timing receipts.
> - It preserves execution policy and keeps provider internals opaque
where the API provides no timing proof.

## Linked Issues or Issue Description

**What existing behavior does this improve?**
Attribution of native task preparation, retained/cold Runner handoff,
and completion-to-text work.

**Current behavior**
Worker spans use a flat provider contract. Workspace calls lack a
consistent dispatch envelope. Submitted-to-started timing is named as a
provider queue even though it includes other work. The transport does
not expose a shared breakdown of its waits.

**Proposed improvement**
Add invocation-scoped nesting, bounded numeric remote receipts,
current-run transport timing, durable receipt intervals, and distinct
completion/text milestones. Reuse the existing exporter. No latency
optimization is included.

Related public work: #17842 consolidates existing startup tracing tests;
it does not add this runtime coverage.

## What Changed

- Extend the optional SDK tracer with nested async scopes and completed
timing receipts. Keep the host responsible for parent identity,
capability checks, attributes, clocks, depth, and count limits.
- Add dispatch and bounded host-read timings for environment and
workspace providers.
- Resolve transport parents on each call so retained sessions do not
retain an earlier run's trace.
- Split cold/resume/attach/prepare/registration/command/started-event
waits. Add opt-in numeric ACPX Runner/sidecar status and prompt-start
receipts.
- Distinguish first completion request, validated tool receipt, final
result commit, first nonempty text, and event persistence. Rename the
submitted-to-started interval to match its meaning.
- Link completed earlier remote lifecycles to their host-owned observer
rather than placing them under a later polling request. Preserve
sampling, timestamp limits and budgets.
- Document the versioned coverage map, causal clock limits, and baseline
acceptance requirements.

## Verification

- Current head: GitHub CI is fully green. Standard Greptile and Apex are
both 5/5, with no unresolved review threads. Full-suite validation was
performed in CI; follow-up local validation was limited to affected
packages and tests.

- Review follow-up: early rejection, missing provider capability and
startup-parent regressions were reproduced and fixed. The affected
worker/SDK/timing set passes 128 tests; 28 timing/context tests, SDK
typecheck and direct server TypeScript checking pass. The server
typecheck wrapper reached an unrelated Runner rebuild and stopped
because Cargo was absent from PATH; full Runner/image validation remains
in CI.

- CI failure accounting: the earlier Turbo cache/force conflict and
Linux OpenCode shell assertion were reproduced and diagnosed. A direct
proc probe ruled out permissions; a portable shell comparison identified
Bash skipping BASH_ENV with stripped SHLVL and socket stdin. Upstream
#18230 now supplies the CI and ignored-stdin fixture fixes; this PR
adopts them without duplicate CI changes. The strict Linux OOM assertion
passed before this source-equivalent merge.
- Retry follow-up: the three insertion/replay/failure timing cases and
server typecheck pass. The existing database regression now asserts a
single marker across identical result replay; the current-head CI
integration gate passes.
- Upstream CI alignment: all three real-Turbo 2.11.10 integration cases
pass. The repository policy gate rejected committing a generated
lockfile; the final PR contains no lockfile edit and uses the maintained
inline stale-lockfile recovery.
- Linked-lifecycle follow-up: 44 focused host/export tests pass (three
optional SDK-dependent cases skip); SDK and server typechecks pass. The
API contract test verifies an explicit root context, host-owned observer
link and unsampled suppression.
- SDK typecheck and focused worker/invocation tests: 30 passed,
including clock changes, bounded legacy IPC, and manual-span
creation-scope parentage.
- Focused reviewed host/native/transaction-boundary checks: 44 passed.
The 86-test worker-manager suite passes after updating its exact
assertions for the host-owned timing budget found by CI. Earlier
preparation/first-text checks also passed.
- Affected server and Runner TypeScript checks pass after building local
workspace declarations.
- Merge follow-up: the profile/candidate startup subset passes 94 tests,
the four Grok source-identity checks pass, and Runner TypeScript
checking passes. A subsequent execution-factory merge passes 21 focused
timing/trace tests and server TypeScript checking.
- Pinned Rust build and the ACPX Rust subset pass. The hostile receipt
projection test passes. Source profile compatibility checks: 127 passed,
one existing skip. Grok identity checks: four passed.
- Broad transport/ACPX suite: 410 passed after building all fake
provider binaries. Earlier attempts failed on missing
Runner/fake-provider outputs; building the required fixtures resolved
those failures.
- The first CI run correctly rejected stale source attestations. After
integrating current master, Grok advances 13→14 and Copilot 84→85;
master’s historical fixtures and Pi 35 stay byte-identical. Provider
dependencies, models and permission defaults are unchanged. Identity
generation/checks now pass.
- The added real-Postgres result lock regression could not initialize
its local embedded cluster. The insertion/replay/failure unit
regressions pass. The current-head CI integration gate passes.
- Comprehensive repository checks and image builds are delegated to CI,
as requested for this instrumentation pass. No deployment, model run,
collector-ingestion proof, or latency improvement is claimed.

## Risks

The new SDK methods are optional. Legacy span calls and endpoint-off
behavior remain supported. The host accepts only closed timing names and
bounded attributes. Remote durations are anchored at local receipt;
transport/scheduling uncertainty remains explicit. Trace trees have
count/depth limits. The first rollout must inspect actual collector
exports before treating the baseline as accepted. Fresh live
qualification for the new source identities remains a rollout
requirement. Revert these commits to remove the new measurements; no
schema migration is required.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning and local code/tool
execution. The exact runtime model ID and context-window size are not
exposed in this session.

## 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-PR following the enhancement
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal issue
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] Standard Greptile and Apex are both 5/5, with no unresolved
findings
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-29 17:45:56 -07:00

1256 lines
46 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createInterface } from "node:readline";
import { PassThrough } from "node:stream";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { definePlugin } from "../src/define-plugin.js";
import {
createRequest,
createErrorResponse,
createSuccessResponse,
isJsonRpcRequest,
isJsonRpcResponse,
isJsonRpcNotification,
parseMessage,
PLUGIN_RPC_ERROR_CODES,
serializeMessage,
type JsonRpcNotification,
type JsonRpcResponse,
type PluginInvocationContext,
} from "../src/protocol.js";
import { isWorkerEntrypoint, startWorkerRpcHost } from "../src/worker-rpc-host.js";
describe("isWorkerEntrypoint", () => {
const tempRoots: string[] = [];
afterEach(() => {
for (const tempRoot of tempRoots.splice(0)) {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
function createTempRoot(): string {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-sdk-worker-"));
tempRoots.push(tempRoot);
return tempRoot;
}
it("matches an entrypoint reached through a symlinked directory", () => {
const tempRoot = createTempRoot();
const realDir = path.join(tempRoot, "real");
const linkDir = path.join(tempRoot, "link");
fs.mkdirSync(realDir);
fs.symlinkSync(realDir, linkDir, "dir");
const workerPath = path.join(realDir, "worker.js");
fs.writeFileSync(workerPath, "");
expect(
isWorkerEntrypoint(
path.join(linkDir, "worker.js"),
pathToFileURL(workerPath).toString(),
),
).toBe(true);
});
it("does not match a different entrypoint", () => {
const tempRoot = createTempRoot();
const workerPath = path.join(tempRoot, "worker.js");
const otherPath = path.join(tempRoot, "other.js");
fs.writeFileSync(workerPath, "");
fs.writeFileSync(otherPath, "");
expect(
isWorkerEntrypoint(
otherPath,
pathToFileURL(workerPath).toString(),
),
).toBe(false);
});
});
describe("worker resource lifecycle delivery", () => {
it("advertises and dispatches the native lifecycle callback", async () => {
const stdin = new PassThrough(), stdout = new PassThrough();
const lines = createInterface({ input: stdout });
const plugin = definePlugin({
async setup() {},
async onResourceLifecycle(event) { return { eventId: event.id, status: "retry" }; },
});
const worker = startWorkerRpcHost({ plugin, stdin, stdout });
async function call(method: string, params: unknown) {
const response = new Promise<JsonRpcResponse>(resolve => {
const handler = (line: string) => {
const message = parseMessage(line);
if (isJsonRpcResponse(message)) { lines.off("line", handler); resolve(message); }
};
lines.on("line", handler);
});
stdin.write(serializeMessage(createRequest(method, params, method)));
return response;
}
try {
const initialized = await call("initialize", {
manifest: { id: "test.lifecycle", apiVersion: 1, version: "1.0.0", displayName: "Lifecycle", description: "Test", author: "Tests", categories: ["automation"], capabilities: ["events.subscribe"], resourceLifecycle: ["project"], entrypoints: { worker: "worker.js" } },
config: {}, databaseNamespace: null,
});
expect(initialized).toMatchObject({ result: { supportedMethods: expect.arrayContaining(["resourceLifecycle"]) } });
expect(await call("resourceLifecycle", { id: "17", companyId: "company", resourceType: "project", resourceId: "project", action: "delete", createdAt: "2020-01-01T00:00:00Z" }))
.toMatchObject({ result: { eventId: "17", status: "retry" } });
} finally { worker.stop(); lines.close(); stdin.destroy(); stdout.destroy(); }
});
});
describe("worker performAction context", () => {
it("does not derive context companyId from caller params without host actor context", async () => {
const hostToWorker = new PassThrough();
const workerToHost = new PassThrough();
const hostReadline = createInterface({ input: workerToHost });
const pending = new Map<string, (response: JsonRpcResponse) => void>();
let nextRequestId = 1;
const plugin = definePlugin({
async setup(ctx) {
ctx.actions.register("inspect", async (params, context) => ({
paramsCompanyId: params.companyId,
actor: context.actor,
companyId: context.companyId,
}));
},
});
const worker = startWorkerRpcHost({
plugin,
stdin: hostToWorker,
stdout: workerToHost,
});
function callWorker(method: string, params: unknown) {
const id = `host-${nextRequestId++}`;
const result = new Promise<unknown>((resolve, reject) => {
pending.set(id, (response) => {
if ("error" in response && response.error) {
reject(new Error(response.error.message));
return;
}
resolve((response as { result?: unknown }).result);
});
});
hostToWorker.write(serializeMessage(createRequest(method, params, id)));
return result;
}
hostReadline.on("line", (line) => {
const message = parseMessage(line);
if (!isJsonRpcResponse(message)) return;
pending.get(String(message.id))?.(message);
pending.delete(String(message.id));
});
try {
await expect(callWorker("initialize", {
manifest: {
id: "paperclip.test-worker-context",
apiVersion: 1,
version: "1.0.0",
displayName: "Worker Context Test",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: [],
entrypoints: {},
},
config: {},
databaseNamespace: null,
})).resolves.toMatchObject({ ok: true });
await expect(callWorker("performAction", {
key: "inspect",
params: { companyId: "spoofed-company" },
})).resolves.toEqual({
paramsCompanyId: "spoofed-company",
actor: {
type: "system",
userId: null,
agentId: null,
runId: null,
companyId: null,
},
companyId: null,
});
} finally {
worker.stop();
hostReadline.close();
hostToWorker.destroy();
workerToHost.destroy();
}
});
});
describe("worker invocation scope propagation", () => {
it("keeps overlapping company scopes local to each getData invocation", async () => {
const hostToWorker = new PassThrough();
const workerToHost = new PassThrough();
const hostReadline = createInterface({ input: workerToHost });
const pending = new Map<string, (response: JsonRpcResponse) => void>();
const nestedInvocationIds: string[] = [];
const invocationCompanies = new Map([
["invocation-a", "company-a"],
["invocation-b", "company-b"],
]);
let releaseCompanyA: (() => void) | null = null;
let nextRequestId = 1;
const plugin = definePlugin({
async setup(ctx) {
ctx.data.register("probe", async (params) => {
if (params.label === "a") {
await new Promise<void>((resolve) => {
releaseCompanyA = resolve;
});
}
const company = await ctx.companies.get(String(params.requestedCompanyId));
return { label: params.label, company };
});
},
});
const worker = startWorkerRpcHost({
plugin,
stdin: hostToWorker,
stdout: workerToHost,
});
function callWorker(method: string, params: unknown, invocation?: PluginInvocationContext) {
const id = `host-${nextRequestId++}`;
const request = {
...createRequest(method, params, id),
...(invocation ? { paperclipInvocation: invocation } : {}),
};
const result = new Promise<unknown>((resolve, reject) => {
pending.set(id, (response) => {
if ("error" in response && response.error) {
reject(new Error(response.error.message));
return;
}
resolve((response as { result?: unknown }).result);
});
});
hostToWorker.write(serializeMessage(request));
return result;
}
hostReadline.on("line", (line) => {
const message = parseMessage(line);
if (isJsonRpcResponse(message)) {
pending.get(String(message.id))?.(message);
pending.delete(String(message.id));
return;
}
if (!isJsonRpcRequest(message)) return;
if (message.method !== "companies.get") return;
const invocationId = (message as { paperclipInvocationId?: string }).paperclipInvocationId ?? "";
const requestedCompanyId = (message.params as { companyId?: string }).companyId;
const allowedCompanyId = invocationCompanies.get(invocationId);
nestedInvocationIds.push(invocationId);
if (requestedCompanyId !== allowedCompanyId) {
hostToWorker.write(serializeMessage(createErrorResponse(
message.id,
PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED,
`requested company "${requestedCompanyId}" but invocation "${invocationId}" is scoped to "${allowedCompanyId}"`,
)));
return;
}
hostToWorker.write(serializeMessage(createSuccessResponse(message.id, {
id: requestedCompanyId,
})));
if (invocationId === "invocation-b") {
releaseCompanyA?.();
}
});
try {
await callWorker("initialize", {
manifest: {
id: "paperclip.scope-test",
apiVersion: 1,
version: "1.0.0",
displayName: "Scope test",
description: "Scope test",
author: "Paperclip",
categories: ["automation"],
capabilities: ["companies.read"],
entrypoints: { worker: "dist/worker.js" },
},
config: {},
instanceInfo: { instanceId: "test", hostVersion: "0.0.0" },
apiVersion: 1,
});
const companyARequest = callWorker(
"getData",
{
key: "probe",
companyId: "company-a",
params: { label: "a", requestedCompanyId: "company-b" },
},
{ id: "invocation-a", scope: { companyId: "company-a" } },
);
const companyAExpectation = expect(companyARequest).rejects.toThrow(
/requested company "company-b"/,
);
const companyBRequest = callWorker(
"getData",
{
key: "probe",
companyId: "company-b",
params: { label: "b", requestedCompanyId: "company-b" },
},
{ id: "invocation-b", scope: { companyId: "company-b" } },
);
await expect(companyBRequest).resolves.toEqual({
label: "b",
company: { id: "company-b" },
});
await companyAExpectation;
expect(nestedInvocationIds).toEqual(["invocation-b", "invocation-a"]);
} finally {
worker.stop();
hostReadline.close();
hostToWorker.destroy();
workerToHost.destroy();
}
});
});
describe("worker configChanged cross-tenant guard", () => {
// Spin up a worker-rpc-host wired to in-memory streams and expose a
// request/response `callWorker` plus `initialize`/`stop` helpers.
function makeWorker(plugin: ReturnType<typeof definePlugin>) {
const hostToWorker = new PassThrough();
const workerToHost = new PassThrough();
const hostReadline = createInterface({ input: workerToHost });
const pending = new Map<string, (response: JsonRpcResponse) => void>();
let nextRequestId = 1;
const worker = startWorkerRpcHost({
plugin,
stdin: hostToWorker,
stdout: workerToHost,
});
function callWorker(method: string, params: unknown) {
const id = `host-${nextRequestId++}`;
const result = new Promise<unknown>((resolve, reject) => {
pending.set(id, (response) => {
if ("error" in response && response.error) {
reject(
Object.assign(new Error(response.error.message), {
code: response.error.code,
}),
);
return;
}
resolve((response as { result?: unknown }).result);
});
});
hostToWorker.write(serializeMessage(createRequest(method, params, id)));
return result;
}
hostReadline.on("line", (line) => {
const message = parseMessage(line);
if (!isJsonRpcResponse(message)) return;
pending.get(String(message.id))?.(message);
pending.delete(String(message.id));
});
async function initialize() {
return await callWorker("initialize", {
manifest: {
id: "paperclip.config-guard-test",
apiVersion: 1,
version: "1.0.0",
displayName: "Config Guard Test",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: [],
entrypoints: {},
},
config: {},
databaseNamespace: null,
});
}
function stop() {
worker.stop();
hostReadline.close();
hostToWorker.destroy();
workerToHost.destroy();
}
return { callWorker, initialize, stop };
}
it.each([false, true])("advertises and dispatches stop-only only with an explicit hook: %s", async supported => {
let releases = 0, stops = 0;
const worker = makeWorker(definePlugin({ async setup() {},
async onEnvironmentReleaseLease() { releases++; return { providerLeaseId: "allocation", state: "destroyed" }; },
...(supported ? { async onEnvironmentStopLease() { stops++; return { providerLeaseId: "allocation", state: "stopped" as const }; } } : {}),
}));
try {
const initialized = await worker.initialize() as { supportedMethods: string[] };
expect(initialized.supportedMethods.includes("environmentStopLease")).toBe(supported);
const stopped = worker.callWorker("environmentStopLease", { driverKey: "fixture", companyId: "company", environmentId: "environment", providerLeaseId: "allocation", config: {} });
if (supported) await expect(stopped).resolves.toEqual({ providerLeaseId: "allocation", state: "stopped" });
else await expect(stopped).rejects.toThrow();
expect(stops).toBe(supported ? 1 : 0);
expect(releases).toBe(0);
} finally { worker.stop(); }
});
it.each([false, true])("advertises read-only inspection only when implemented: %s", async supported => {
let inspections = 0, mutations = 0;
const worker = makeWorker(definePlugin({ async setup() {},
async onEnvironmentReleaseLease() { mutations++; },
async onEnvironmentDestroyLease() { mutations++; },
...(supported ? { async onEnvironmentInspectLease() { inspections++; return { providerLeaseId: "allocation", state: "destroyed" as const }; } } : {}),
}));
try {
const initialized = await worker.initialize() as { supportedMethods: string[] };
expect(initialized.supportedMethods.includes("environmentInspectLease")).toBe(supported);
const result = worker.callWorker("environmentInspectLease", { driverKey: "fixture", companyId: "company", environmentId: "environment", providerLeaseId: "allocation", config: {} });
if (supported) await expect(result).resolves.toEqual({ providerLeaseId: "allocation", state: "destroyed" });
else await expect(result).rejects.toThrow();
expect(inspections).toBe(supported ? 1 : 0);
expect(mutations).toBe(0);
} finally { worker.stop(); }
});
it("fails closed when a second, distinct company's config would overwrite a single-tenant worker", async () => {
const applied: Array<{ companyId: string | null; token: unknown }> = [];
const plugin = definePlugin({
async setup() {},
async onConfigChanged(newConfig, context) {
applied.push({
companyId: context?.companyId ?? null,
token: newConfig.slackBotToken,
});
},
});
const { callWorker, initialize, stop } = makeWorker(plugin);
try {
await initialize();
// Company A's config is delivered first (deterministic ORDER BY companyId
// in the loader) and applied.
await expect(
callWorker("configChanged", {
config: { companyId: "company-a", slackBotToken: "xoxb-A" },
companyId: "company-a",
}),
).resolves.toBeNull();
// Company B's *distinct* config must be rejected rather than silently
// collapsing the single worker onto B's bot token (the vulnerability).
await expect(
callWorker("configChanged", {
config: { companyId: "company-b", slackBotToken: "xoxb-B" },
companyId: "company-b",
}),
).rejects.toMatchObject({
code: PLUGIN_RPC_ERROR_CODES.CROSS_TENANT_CONFIG,
});
// The worker stayed bound to company A; company B never reached the
// plugin. Against the pre-fix code this array would be
// [company-a, company-b] (last-write-wins collapse).
expect(applied).toEqual([{ companyId: "company-a", token: "xoxb-A" }]);
} finally {
stop();
}
});
it("allows an idempotent replay of the same config under a different scope row", async () => {
// Mirrors the live single-tenant gateway: several plugin_config rows keyed
// by distinct row companyIds but all embedding the same config. Replaying
// them must be a no-op, not a fail-closed rejection.
const appliedScopes: Array<string | null> = [];
const plugin = definePlugin({
async setup() {},
async onConfigChanged(_newConfig, context) {
appliedScopes.push(context?.companyId ?? null);
},
});
const { callWorker, initialize, stop } = makeWorker(plugin);
try {
await initialize();
const embedded = { companyId: "company-a", slackBotToken: "xoxb-A" };
await callWorker("configChanged", {
config: { ...embedded },
companyId: "row-scope-1",
});
await expect(
callWorker("configChanged", {
config: { ...embedded },
companyId: "row-scope-2",
}),
).resolves.toBeNull();
expect(appliedScopes).toEqual(["row-scope-1", "row-scope-2"]);
} finally {
stop();
}
});
it("threads per-company config to a plugin that opts into multiCompanyConfig", async () => {
const applied: Array<{ companyId: string | null; token: unknown }> = [];
const plugin = definePlugin({
multiCompanyConfig: true,
async setup() {},
async onConfigChanged(newConfig, context) {
applied.push({
companyId: context?.companyId ?? null,
token: newConfig.slackBotToken,
});
},
});
const { callWorker, initialize, stop } = makeWorker(plugin);
try {
await initialize();
await callWorker("configChanged", {
config: { companyId: "company-a", slackBotToken: "xoxb-A" },
companyId: "company-a",
});
await expect(
callWorker("configChanged", {
config: { companyId: "company-b", slackBotToken: "xoxb-B" },
companyId: "company-b",
}),
).resolves.toBeNull();
// Both companies' configs delivered, each tagged with its own scope.
expect(applied).toEqual([
{ companyId: "company-a", token: "xoxb-A" },
{ companyId: "company-b", token: "xoxb-B" },
]);
} finally {
stop();
}
});
});
describe("worker provider tracer", () => {
it("default plugin tracer is a no-op that starts and ends a span without throwing", async () => {
const { NOOP_PLUGIN_TRACER } = await import("../src/types.js");
const span = NOOP_PLUGIN_TRACER.startSpan("pack", { attributes: { a: 1 } });
expect(() => {
span.setAttribute("b", 2);
span.setStatus({ code: 1 });
span.end();
}).not.toThrow();
});
// Drive a plugin data handler that opens a provider span, and capture the
// worker→host traffic. The host injects a `traceparent` on the invocation, so
// the worker must emit one `span.record` request that echoes the invocation id
// and carries the span name and attributes.
async function runSpanProbe(invocation: PluginInvocationContext, count=1, capabilities = ["environment.drivers.register"]) {
const hostToWorker = new PassThrough();
const workerToHost = new PassThrough();
const hostReadline = createInterface({ input: workerToHost });
const pending = new Map<string, (response: JsonRpcResponse) => void>();
const spanRecords: Array<{ params: unknown; invocationId?: string }> = [];
let nextRequestId = 1;
const plugin = definePlugin({
async setup(ctx) {
ctx.data.register("probe", async () => {
for(let index=0;index<count;index++) {
const span = ctx.tracer.startSpan("pack", {
attributes: { "paperclip.sandbox.startup.pack.wall_ms": 12 },
});
span.setAttribute("paperclip.sandbox.startup.provider", "daytona");
span.end();
}
return { ok: true };
});
},
});
const worker = startWorkerRpcHost({ plugin, stdin: hostToWorker, stdout: workerToHost });
function callWorker(method: string, params: unknown, inv?: PluginInvocationContext) {
const id = `host-${nextRequestId++}`;
const request = {
...createRequest(method, params, id),
...(inv ? { paperclipInvocation: inv } : {}),
};
const result = new Promise<unknown>((resolve, reject) => {
pending.set(id, (response) => {
if ("error" in response && response.error) {
reject(new Error(response.error.message));
return;
}
resolve((response as { result?: unknown }).result);
});
});
hostToWorker.write(serializeMessage(request));
return result;
}
hostReadline.on("line", (line) => {
const message = parseMessage(line);
if (isJsonRpcResponse(message)) {
pending.get(String(message.id))?.(message);
pending.delete(String(message.id));
return;
}
if (!isJsonRpcRequest(message)) return;
if (message.method === "span.record") {
spanRecords.push({
params: message.params,
invocationId: (message as { paperclipInvocationId?: string }).paperclipInvocationId,
});
hostToWorker.write(serializeMessage(createSuccessResponse(message.id, null)));
}
});
try {
await callWorker("initialize", {
manifest: {
id: "paperclip.tracer-test",
apiVersion: 1,
version: "1.0.0",
displayName: "Tracer test",
description: "Tracer test",
author: "Paperclip",
categories: ["automation"],
capabilities,
entrypoints: { worker: "dist/worker.js" },
},
config: {},
instanceInfo: { instanceId: "test", hostVersion: "0.0.0" },
apiVersion: 1,
});
await callWorker("getData", { key: "probe", companyId: "company-a", params: {} }, invocation);
// Let the fire-and-forget span.record flush.
await new Promise((resolve) => setTimeout(resolve, 20));
return spanRecords;
} finally {
worker.stop();
hostReadline.close();
hostToWorker.destroy();
workerToHost.destroy();
}
}
it("emits one span.record with the name and attributes when a host trace context is active", async () => {
const spanRecords = await runSpanProbe({
id: "invocation-a",
scope: { companyId: "company-a" },
traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
});
expect(spanRecords).toHaveLength(1);
const record = spanRecords[0]!;
expect(record.invocationId).toBe("invocation-a");
expect(record.params).toMatchObject({name:"plugin.worker"});
expect((record.params as {children:unknown[]}).children[0]).toMatchObject({
name: "pack",
attributes: {
"paperclip.sandbox.startup.pack.wall_ms": 12,
"paperclip.sandbox.startup.provider": "daytona",
},
});
});
it("sends a finite startTimeMs and endTimeMs with endTimeMs >= startTimeMs", async () => {
const spanRecords = await runSpanProbe({
id: "invocation-a",
scope: { companyId: "company-a" },
traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
});
expect(spanRecords).toHaveLength(1);
const params = spanRecords[0]!.params as {
startTimeMs?: number;
endTimeMs?: number;
};
expect(Number.isFinite(params.startTimeMs)).toBe(true);
expect(Number.isFinite(params.endTimeMs)).toBe(true);
expect(params.endTimeMs!).toBeGreaterThanOrEqual(params.startTimeMs!);
});
it("never falls back to per-span IPC after the legacy span budget is exhausted",async()=>{
const records=await runSpanProbe({id:"bounded",scope:{companyId:"company-a"},traceparent:"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"},300);
expect(records).toHaveLength(1);
expect((records[0].params as {children:unknown[]}).children).toHaveLength(255);
});
it.each([{ capabilities: [] }, { capabilities: ["companies.read"] }])("does not collect automatic or legacy receipts without a permitted capability: $capabilities", async ({ capabilities }) => {
const records = await runSpanProbe({ id: "not-permitted", scope: { companyId: "company-a" },
traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" }, 1, capabilities);
expect(records).toEqual([]);
});
it("collects receipts for workspace providers without environment-driver permission", async () => {
const records = await runSpanProbe({ id: "workspace", scope: { companyId: "company-a" },
traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" }, 1, ["workspaces.manage"]);
expect(records).toHaveLength(1);
expect(records[0].params).toMatchObject({ name: "plugin.worker", children: [{ name: "pack" }] });
});
it("emits no span.record when the invocation carries no traceparent (tracing off)", async () => {
const spanRecords = await runSpanProbe({
id: "invocation-a",
scope: { companyId: "company-a" },
});
expect(spanRecords).toHaveLength(0);
});
});
describe("worker execute.log emitter", () => {
// Run one data handler that calls `ctx.execution.log`, and capture the
// `execute.log` notifications the worker sends to the host.
async function runExecuteLogProbe(
invocation: PluginInvocationContext | undefined,
entries: Array<{ stream: "stdout" | "stderr"; chunk: string }>,
) {
const hostToWorker = new PassThrough();
const workerToHost = new PassThrough();
const hostReadline = createInterface({ input: workerToHost });
const pending = new Map<string, (response: JsonRpcResponse) => void>();
const logRecords: Array<{ params: unknown; invocationId?: string }> = [];
let nextRequestId = 1;
const plugin = definePlugin({
async setup(ctx) {
ctx.data.register("emit-logs", async () => {
for (const entry of entries) {
ctx.execution.log(entry.stream, entry.chunk);
}
return { ok: true };
});
},
});
const worker = startWorkerRpcHost({ plugin, stdin: hostToWorker, stdout: workerToHost });
function callWorker(method: string, params: unknown, inv?: PluginInvocationContext) {
const id = `host-${nextRequestId++}`;
const request = {
...createRequest(method, params, id),
...(inv ? { paperclipInvocation: inv } : {}),
};
const result = new Promise<unknown>((resolve, reject) => {
pending.set(id, (response) => {
if ("error" in response && response.error) {
reject(new Error(response.error.message));
return;
}
resolve((response as { result?: unknown }).result);
});
});
hostToWorker.write(serializeMessage(request));
return result;
}
hostReadline.on("line", (line) => {
const message = parseMessage(line);
if (isJsonRpcResponse(message)) {
pending.get(String(message.id))?.(message);
pending.delete(String(message.id));
return;
}
// `execute.log` is a fire-and-forget notification (no id), so it is not a
// JSON-RPC request. Match on the method name directly.
if ((message as { method?: string }).method === "execute.log") {
logRecords.push({
params: (message as { params?: unknown }).params,
invocationId: (message as { paperclipInvocationId?: string }).paperclipInvocationId,
});
}
});
try {
await callWorker("initialize", {
manifest: {
id: "paperclip.execute-log-test",
apiVersion: 1,
version: "1.0.0",
displayName: "Execute log test",
description: "Execute log test",
author: "Paperclip",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: { worker: "dist/worker.js" },
},
config: {},
instanceInfo: { instanceId: "test", hostVersion: "0.0.0" },
apiVersion: 1,
});
await callWorker("getData", { key: "emit-logs", companyId: "company-a", params: {} }, invocation);
// Let the fire-and-forget execute.log notifications flush.
await new Promise((resolve) => setTimeout(resolve, 20));
return logRecords;
} finally {
worker.stop();
hostReadline.close();
hostToWorker.destroy();
workerToHost.destroy();
}
}
it("stamps the active invocation id on each execute.log notification", async () => {
const records = await runExecuteLogProbe(
{ id: "invocation-a", scope: { companyId: "company-a" } },
[
{ stream: "stdout", chunk: "one" },
{ stream: "stderr", chunk: "two" },
],
);
expect(records).toHaveLength(2);
expect(records[0]).toEqual({
params: { stream: "stdout", chunk: "one" },
invocationId: "invocation-a",
});
expect(records[1]).toEqual({
params: { stream: "stderr", chunk: "two" },
invocationId: "invocation-a",
});
});
it("drops an empty chunk before it reaches the host", async () => {
const records = await runExecuteLogProbe(
{ id: "invocation-a", scope: { companyId: "company-a" } },
[
{ stream: "stdout", chunk: "" },
{ stream: "stdout", chunk: "kept" },
],
);
expect(records).toEqual([
{ params: { stream: "stdout", chunk: "kept" }, invocationId: "invocation-a" },
]);
});
});
describe("worker setup-token pseudo-terminal dispatch", () => {
it("dispatches open, input, stop, and close, and streams output and exit as notifications", async () => {
const hostToWorker = new PassThrough();
const workerToHost = new PassThrough();
const hostReadline = createInterface({ input: workerToHost });
const pending = new Map<string, (response: JsonRpcResponse) => void>();
const notifications: JsonRpcNotification[] = [];
let nextRequestId = 1;
// The fake session the opener returns. The test drives its output and exit.
let emitOutput: ((chunk: string) => void) | null = null;
let resolveWait: ((value: { exitCode: number | null }) => void) | null = null;
const inputs: string[] = [];
let killed = 0;
let closed = 0;
// The worker emits output and exit through `ctx.loginPty`, bound to the
// worker session id. The test drives them through the captured emitters.
const controllablePlugin = definePlugin({
async setup(ctx) {
emitOutput = (chunk: string) => ctx.loginPty.output("route-1", "ws-1", chunk);
resolveWait = (value) => ctx.loginPty.exit("route-1", "ws-1", value.exitCode);
},
async onLoginPtyOpen(params) {
// The open carries the host route id, the closed command key, and the
// validated session home. The worker returns a worker session id for the
// output binding only. The open carries no command string.
expect(params.hostRouteId).toBe("route-1");
expect(params.loginCommandKey).toBe("claude");
expect(params.sessionHome).toBe(
"/tmp/paperclip-adapter-login/11111111-2222-4333-8444-555555555555",
);
expect(params.providerLeaseId).toBe("lease-1");
return { workerSessionId: "ws-1" };
},
async onLoginPtyInput(params) {
inputs.push(params.data);
},
async onLoginPtyStop() {
killed += 1;
},
async onLoginPtyClose(params) {
// The close keys on the host route id and returns a bound acknowledgement.
closed += 1;
return { hostRouteId: params.hostRouteId };
},
});
const worker = startWorkerRpcHost({
plugin: controllablePlugin,
stdin: hostToWorker,
stdout: workerToHost,
});
function callWorker(method: string, params: unknown) {
const id = `host-${nextRequestId++}`;
const result = new Promise<unknown>((resolve, reject) => {
pending.set(id, (response) => {
if ("error" in response && response.error) {
reject(new Error(response.error.message));
return;
}
resolve((response as { result?: unknown }).result);
});
});
hostToWorker.write(serializeMessage(createRequest(method, params, id)));
return result;
}
hostReadline.on("line", (line) => {
const message = parseMessage(line);
if (isJsonRpcResponse(message)) {
pending.get(String(message.id))?.(message);
pending.delete(String(message.id));
return;
}
if (isJsonRpcNotification(message)) {
notifications.push(message as JsonRpcNotification);
}
});
try {
await expect(
callWorker("initialize", {
manifest: {
id: "paperclip.login-pty",
apiVersion: 1,
version: "1.0.0",
displayName: "Login PTY Test",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: [],
entrypoints: {},
},
config: {},
databaseNamespace: null,
}),
).resolves.toMatchObject({
ok: true,
supportedMethods: expect.arrayContaining([
"loginPtyOpen",
"loginPtyInput",
"loginPtyStop",
"loginPtyClose",
]),
});
await expect(
callWorker("loginPtyOpen", {
hostRouteId: "route-1",
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "lease-1",
loginCommandKey: "claude",
sessionHome: "/tmp/paperclip-adapter-login/11111111-2222-4333-8444-555555555555",
}),
).resolves.toEqual({ workerSessionId: "ws-1" });
// The worker streams output as a notification bound to the worker session id.
emitOutput?.("prompt output");
await callWorker("loginPtyInput", { workerSessionId: "ws-1", data: "browser-code" });
await callWorker("loginPtyStop", { workerSessionId: "ws-1" });
resolveWait?.({ exitCode: 0 });
await expect(
callWorker("loginPtyClose", { hostRouteId: "route-1" }),
).resolves.toEqual({ hostRouteId: "route-1" });
await new Promise((resolve) => setImmediate(resolve));
expect(inputs).toEqual(["browser-code"]);
expect(killed).toBe(1);
expect(closed).toBe(1);
const outputNotes = notifications.filter(
(note) => note.method === "loginPty.output",
);
expect(outputNotes.map((note) => note.params)).toEqual([
{ hostRouteId: "route-1", workerSessionId: "ws-1", chunk: "prompt output" },
]);
const exitNotes = notifications.filter(
(note) => note.method === "loginPty.exit",
);
expect(exitNotes.map((note) => note.params)).toEqual([
{ hostRouteId: "route-1", workerSessionId: "ws-1", exitCode: 0 },
]);
} finally {
worker.stop();
hostReadline.close();
}
});
});
describe("worker duplex channel dispatch", () => {
it("dispatches open, write, stop, and close, and reports the duplex methods", async () => {
const hostToWorker = new PassThrough();
const workerToHost = new PassThrough();
const hostReadline = createInterface({ input: workerToHost });
const pending = new Map<string, (response: JsonRpcResponse) => void>();
let nextRequestId = 1;
const writes: string[] = [];
let stopped = 0;
let closed = 0;
// The plugin declares the four duplex channel handlers. The open returns a
// worker session id. The close keys on the host route id and returns a bound
// acknowledgement.
const controllablePlugin = definePlugin({
async setup() {},
async onDuplexChannelOpen(params) {
expect(params.hostRouteId).toBe("route-1");
expect(params.command).toEqual(["paperclip-bridge"]);
expect(params.providerLeaseId).toBe("lease-1");
return { workerSessionId: "ws-1" };
},
async onDuplexChannelWrite(params) {
writes.push(params.data);
},
async onDuplexChannelStop() {
stopped += 1;
},
async onDuplexChannelClose(params) {
closed += 1;
return { hostRouteId: params.hostRouteId };
},
});
const worker = startWorkerRpcHost({
plugin: controllablePlugin,
stdin: hostToWorker,
stdout: workerToHost,
});
function callWorker(method: string, params: unknown) {
const id = `host-${nextRequestId++}`;
const result = new Promise<unknown>((resolve, reject) => {
pending.set(id, (response) => {
if ("error" in response && response.error) {
reject(new Error(response.error.message));
return;
}
resolve((response as { result?: unknown }).result);
});
});
hostToWorker.write(serializeMessage(createRequest(method, params, id)));
return result;
}
hostReadline.on("line", (line) => {
const message = parseMessage(line);
if (isJsonRpcResponse(message)) {
pending.get(String(message.id))?.(message);
pending.delete(String(message.id));
}
});
try {
await expect(
callWorker("initialize", {
manifest: {
id: "paperclip.duplex-channel",
apiVersion: 1,
version: "1.0.0",
displayName: "Duplex Channel Test",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: [],
entrypoints: {},
},
config: {},
databaseNamespace: null,
}),
).resolves.toMatchObject({
ok: true,
supportedMethods: expect.arrayContaining([
"duplexChannelOpen",
"duplexChannelWrite",
"duplexChannelStop",
"duplexChannelClose",
]),
});
await expect(
callWorker("duplexChannelOpen", {
hostRouteId: "route-1",
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "lease-1",
command: ["paperclip-bridge"],
}),
).resolves.toEqual({ workerSessionId: "ws-1" });
await callWorker("duplexChannelWrite", { workerSessionId: "ws-1", data: "payload" });
await callWorker("duplexChannelStop", { workerSessionId: "ws-1" });
await expect(
callWorker("duplexChannelClose", { hostRouteId: "route-1" }),
).resolves.toEqual({ hostRouteId: "route-1" });
expect(writes).toEqual(["payload"]);
expect(stopped).toBe(1);
expect(closed).toBe(1);
} finally {
worker.stop();
hostReadline.close();
}
});
it("reports no duplex methods when the plugin declares no duplex handlers", async () => {
const hostToWorker = new PassThrough();
const workerToHost = new PassThrough();
const hostReadline = createInterface({ input: workerToHost });
const pending = new Map<string, (response: JsonRpcResponse) => void>();
let nextRequestId = 1;
// A plugin with no duplex handlers advertises no duplex method. The Phase 1
// capability `duplexCommandStream` still resolves false for this provider,
// because the prerequisite verb `duplexChannelOpen` is absent.
const barePlugin = definePlugin({
async setup() {},
});
const worker = startWorkerRpcHost({
plugin: barePlugin,
stdin: hostToWorker,
stdout: workerToHost,
});
function callWorker(method: string, params: unknown) {
const id = `host-${nextRequestId++}`;
const result = new Promise<unknown>((resolve, reject) => {
pending.set(id, (response) => {
if ("error" in response && response.error) {
reject(new Error(response.error.message));
return;
}
resolve((response as { result?: unknown }).result);
});
});
hostToWorker.write(serializeMessage(createRequest(method, params, id)));
return result;
}
hostReadline.on("line", (line) => {
const message = parseMessage(line);
if (isJsonRpcResponse(message)) {
pending.get(String(message.id))?.(message);
pending.delete(String(message.id));
}
});
try {
const result = (await callWorker("initialize", {
manifest: {
id: "paperclip.bare",
apiVersion: 1,
version: "1.0.0",
displayName: "Bare Test",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: [],
entrypoints: {},
},
config: {},
databaseNamespace: null,
})) as { supportedMethods: string[] };
expect(result.supportedMethods).not.toContain("duplexChannelOpen");
expect(result.supportedMethods).not.toContain("duplexChannelWrite");
expect(result.supportedMethods).not.toContain("duplexChannelStop");
expect(result.supportedMethods).not.toContain("duplexChannelClose");
} finally {
worker.stop();
hostReadline.close();
}
});
});
describe("AI connection router RPC", () => {
it("advertises and calls only an implemented routing hook", async () => {
const input = new PassThrough(), output = new PassThrough();
const reader = createInterface({ input: output });
const request = { companyId: "company", taskKey: "task", candidates: [], memberOrder: [] };
let received: unknown;
const plugin = definePlugin({ async setup() {}, onRouteAiConnection(params) { received = params; return { kind: "selected", memberId: "authorized-member" }; } });
const worker = startWorkerRpcHost({ plugin, stdin: input, stdout: output });
let sequence = 0;
const pending = new Map<string, (value: unknown) => void>();
reader.on("line", line => { const response = parseMessage(line); if (isJsonRpcResponse(response)) { pending.get(String(response.id))?.(response); pending.delete(String(response.id)); } });
const call = (method: string, params: unknown) => new Promise<JsonRpcResponse>(resolve => { const id = String(++sequence); pending.set(id, value => resolve(value as JsonRpcResponse)); input.write(serializeMessage(createRequest(method, params, id))); });
try {
const initialized = await call("initialize", { manifest: { id: "fixture.router", apiVersion: 1, version: "1.0.0", displayName: "Router", description: "Fixture", author: "Tests", categories: ["connector"], capabilities: ["ai.connections.route"], entrypoints: {} }, config: {}, databaseNamespace: null });
expect((initialized as { result: { supportedMethods: string[] } }).result.supportedMethods).toContain("routeAiConnection");
expect((await call("routeAiConnection", request) as { result: unknown }).result).toEqual({ kind: "selected", memberId: "authorized-member" });
expect(received).toEqual(request);
} finally { worker.stop(); reader.close(); input.destroy(); output.destroy(); }
});
});
describe("Durable lifecycle inbox RPC", () => {
it("sends company-scoped reads and acknowledgments through the worker SDK", async () => {
const input = new PassThrough(), output = new PassThrough();
const reader = createInterface({ input: output });
const calls: Array<{ method: string; params: unknown }> = [];
const event = { id: "7", companyId: "company-a", resourceType: "agent", resourceId: "agent-a", action: "pause", createdAt: new Date(0).toISOString() };
const plugin = definePlugin({ async setup(ctx) {
ctx.data.register("drain", async ({ companyId }) => {
const events = await ctx.events.listLifecycle(companyId!, 10, "6");
await ctx.events.acknowledgeLifecycle(companyId!, events[0].id);
await ctx.agents.getRuntime("agent-a", companyId!, "environment-a", true);
return events;
});
} });
const worker = startWorkerRpcHost({ plugin, stdin: input, stdout: output });
let sequence = 0;
const pending = new Map<string, (value: JsonRpcResponse) => void>();
reader.on("line", line => {
const message = parseMessage(line);
if (isJsonRpcResponse(message)) {
pending.get(String(message.id))?.(message);
pending.delete(String(message.id));
} else if (isJsonRpcRequest(message)) {
calls.push({ method: message.method, params: message.params });
input.write(serializeMessage(createSuccessResponse(message.id, message.method === "events.listLifecycle" ? [event] : null)));
}
});
const call = (method: string, params: unknown) => new Promise<JsonRpcResponse>(resolve => {
const id = String(++sequence);
pending.set(id, resolve);
input.write(serializeMessage(createRequest(method, params, id)));
});
try {
await call("initialize", { manifest: { id: "fixture.lifecycle", apiVersion: 1, version: "1.0.0", displayName: "Lifecycle", description: "Fixture", author: "Tests", categories: ["automation"], capabilities: ["events.subscribe", "agents.lifecycle.manage"], entrypoints: {} }, config: {}, databaseNamespace: null });
expect((await call("getData", { key: "drain", companyId: "company-a", params: {} }) as { result: unknown }).result).toEqual([event]);
expect(calls).toEqual([
{ method: "events.listLifecycle", params: { companyId: "company-a", limit: 10, afterId: "6" } },
{ method: "events.acknowledgeLifecycle", params: { companyId: "company-a", eventId: "7" } },
{ method: "agents.getRuntime", params: { companyId: "company-a", agentId: "agent-a", environmentId: "environment-a", installEnvironmentSkills: true } },
]);
} finally { worker.stop(); reader.close(); input.destroy(); output.destroy(); }
});
});