mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
## 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>
1256 lines
46 KiB
TypeScript
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(); }
|
|
});
|
|
});
|