Fix plugin company-scoped config resolution

This commit is contained in:
Paperclip Bot 2026-06-03 14:08:15 +00:00
parent 5317029ef4
commit 0509d08f3c
8 changed files with 505 additions and 84 deletions

View file

@ -1510,6 +1510,7 @@ export interface AgentSession {
export interface AgentSessionEvent {
sessionId: string;
runId: string;
companyId?: string | null;
seq: number;
/** The kind of event: "chunk" for output data, "status" for run state changes, "done" for end-of-stream, "error" for failures. */
eventType: "chunk" | "status" | "done" | "error";

View file

@ -168,6 +168,16 @@ interface RuntimeCompanyContext {
companyId?: string | null;
}
function runtimeCompanyParams(
params: { companyId?: string | null } | undefined,
fallbackCompanyId: string | null | undefined,
): { companyId?: string | null } {
if (params && Object.prototype.hasOwnProperty.call(params, "companyId")) {
return { companyId: params.companyId };
}
return fallbackCompanyId == null ? {} : { companyId: fallbackCompanyId };
}
// ---------------------------------------------------------------------------
// Internal: event registration
// ---------------------------------------------------------------------------
@ -411,6 +421,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
// -----------------------------------------------------------------------
function buildContext(): PluginContext {
const currentCompanyId = () =>
runtimeCompanyContext.getStore()?.companyId ??
invocationContextStorage.getStore()?.scope.companyId ??
null;
return {
get manifest() {
if (!manifest) throw new Error("Plugin context accessed before initialization");
@ -419,9 +434,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
config: {
async get(params) {
const companyId =
params?.companyId ?? runtimeCompanyContext.getStore()?.companyId ?? null;
return callHost("config.get", companyId ? { companyId } : {});
return callHost("config.get", runtimeCompanyParams(
params,
currentCompanyId(),
));
},
},
@ -572,8 +588,16 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
secrets: {
async resolve(secretRef: string, companyId?: string | null): Promise<string> {
const scopedCompanyId = companyId ?? runtimeCompanyContext.getStore()?.companyId ?? null;
return callHost("secrets.resolve", { secretRef, companyId: scopedCompanyId });
const providedCompanyParams = arguments.length > 1
? { companyId }
: undefined;
return callHost("secrets.resolve", {
secretRef,
...runtimeCompanyParams(
providedCompanyParams,
currentCompanyId(),
),
});
},
},
@ -1779,7 +1803,21 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
if (notif.method === "agents.sessions.event" && notif.params) {
const event = notif.params as AgentSessionEvent;
const cb = sessionEventCallbacks.get(event.sessionId);
if (cb) cb(event);
if (cb) {
Promise.resolve(
runNotification(() =>
runtimeCompanyContext.run(
{ companyId: event.companyId ?? null },
() => cb(event),
),
),
).catch((err) => {
notifyHost("log", {
level: "error",
message: `Failed to handle agent session event: ${err instanceof Error ? err.message : String(err)}`,
});
});
}
} else if (notif.method === "onEvent" && notif.params) {
// Plugin event bus notifications — dispatch to registered event handlers
Promise.resolve(runNotification(() => handleOnEvent(notif.params as OnEventParams))).catch((err) => {

View file

@ -354,66 +354,273 @@ describe("startWorkerRpcHost runtime company context", () => {
});
const host = startWorkerRpcHost({ plugin, stdin, stdout });
try {
writeMessage(stdin, {
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
manifest: { id: "test-plugin", name: "test-plugin", version: "1.0.0" },
config: {},
instanceInfo: { instanceId: "inst-1", hostVersion: "0.0.0-test" },
apiVersion: 1,
},
});
await expect(nextMessage()).resolves.toMatchObject({ id: 1, result: { ok: true } });
writeMessage(stdin, {
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
manifest: { id: "test-plugin", name: "test-plugin", version: "1.0.0" },
config: {},
instanceInfo: { instanceId: "inst-1", hostVersion: "0.0.0-test" },
apiVersion: 1,
writeMessage(stdin, {
jsonrpc: "2.0",
id: 2,
method: "executeTool",
params: {
toolName: "check-context",
parameters: {},
runContext: {
agentId: "agent-1",
runId: "run-1",
companyId: "company-1",
projectId: "project-1",
},
},
});
const configRequest = await nextMessage();
expect(configRequest).toMatchObject({
method: "config.get",
params: { companyId: "company-1" },
});
writeMessage(stdin, {
jsonrpc: "2.0",
id: configRequest.id,
result: { mode: "company-config" },
});
const secretRequest = await nextMessage();
expect(secretRequest).toMatchObject({
method: "secrets.resolve",
params: {
secretRef: "77777777-7777-4777-8777-777777777777",
companyId: "company-1",
},
});
writeMessage(stdin, {
jsonrpc: "2.0",
id: secretRequest.id,
result: "company-secret",
});
await expect(nextMessage()).resolves.toMatchObject({
id: 2,
result: { content: "company-config:company-secret" },
});
} finally {
host.stop();
stdin.end();
stdout.destroy();
}
});
it("preserves explicit null company context overrides", async () => {
const stdin = new PassThrough();
const stdout = new PassThrough();
const nextMessage = collectJsonLines(stdout);
const plugin = definePlugin({
async setup(ctx) {
ctx.tools.register(
"clear-context",
{
displayName: "Clear Context",
description: "Checks explicit null company override propagation",
parametersSchema: { type: "object", properties: {} },
},
async () => {
const config = await ctx.config.get({ companyId: null });
const token = await ctx.secrets.resolve(
"77777777-7777-4777-8777-777777777777",
null,
);
return { content: `${config.mode}:${token}` };
},
);
},
});
await expect(nextMessage()).resolves.toMatchObject({ id: 1, result: { ok: true } });
writeMessage(stdin, {
jsonrpc: "2.0",
id: 2,
method: "executeTool",
params: {
toolName: "check-context",
parameters: {},
runContext: {
agentId: "agent-1",
const host = startWorkerRpcHost({ plugin, stdin, stdout });
try {
writeMessage(stdin, {
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
manifest: { id: "test-plugin", name: "test-plugin", version: "1.0.0" },
config: {},
instanceInfo: { instanceId: "inst-1", hostVersion: "0.0.0-test" },
apiVersion: 1,
},
});
await expect(nextMessage()).resolves.toMatchObject({ id: 1, result: { ok: true } });
writeMessage(stdin, {
jsonrpc: "2.0",
id: 2,
method: "executeTool",
params: {
toolName: "clear-context",
parameters: {},
runContext: {
agentId: "agent-1",
runId: "run-1",
companyId: "company-1",
projectId: "project-1",
},
},
});
const configRequest = await nextMessage();
expect(configRequest).toMatchObject({
method: "config.get",
params: { companyId: null },
});
writeMessage(stdin, {
jsonrpc: "2.0",
id: configRequest.id,
result: { mode: "global-config" },
});
const secretRequest = await nextMessage();
expect(secretRequest).toMatchObject({
method: "secrets.resolve",
params: {
secretRef: "77777777-7777-4777-8777-777777777777",
companyId: null,
},
});
writeMessage(stdin, {
jsonrpc: "2.0",
id: secretRequest.id,
result: "global-secret",
});
await expect(nextMessage()).resolves.toMatchObject({
id: 2,
result: { content: "global-config:global-secret" },
});
} finally {
host.stop();
stdin.end();
stdout.destroy();
}
});
it("passes session event company context into config host calls", async () => {
const stdin = new PassThrough();
const stdout = new PassThrough();
const nextMessage = collectJsonLines(stdout);
const plugin = definePlugin({
async setup(ctx) {
ctx.data.register("start-session", async () => {
await ctx.agents.sessions.sendMessage("session-1", "company-1", {
prompt: "hello",
onEvent: async () => {
await ctx.config.get();
},
});
return { ok: true };
});
},
});
const host = startWorkerRpcHost({ plugin, stdin, stdout });
try {
writeMessage(stdin, {
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
manifest: {
id: "test-plugin",
name: "test-plugin",
version: "1.0.0",
capabilities: ["agent.sessions.send"],
},
config: {},
instanceInfo: { instanceId: "inst-1", hostVersion: "0.0.0-test" },
apiVersion: 1,
},
});
await expect(nextMessage()).resolves.toMatchObject({ id: 1, result: { ok: true } });
writeMessage(stdin, {
jsonrpc: "2.0",
id: 2,
method: "getData",
params: {
key: "start-session",
companyId: "company-1",
params: {},
},
paperclipInvocation: {
id: "invocation-session",
scope: { companyId: "company-1" },
},
});
const sendMessageRequest = await nextMessage();
expect(sendMessageRequest).toMatchObject({
method: "agents.sessions.sendMessage",
params: {
sessionId: "session-1",
companyId: "company-1",
prompt: "hello",
},
});
writeMessage(stdin, {
jsonrpc: "2.0",
id: sendMessageRequest.id,
result: { runId: "run-1" },
});
await expect(nextMessage()).resolves.toMatchObject({
id: 2,
result: { ok: true },
});
writeMessage(stdin, {
jsonrpc: "2.0",
method: "agents.sessions.event",
params: {
sessionId: "session-1",
runId: "run-1",
companyId: "company-1",
projectId: "project-1",
seq: 1,
eventType: "chunk",
stream: "stdout",
message: "hello",
payload: null,
},
},
});
paperclipInvocation: {
id: "invocation-session",
scope: { companyId: "company-1" },
},
});
const configRequest = await nextMessage();
expect(configRequest).toMatchObject({
method: "config.get",
params: { companyId: "company-1" },
});
writeMessage(stdin, {
jsonrpc: "2.0",
id: configRequest.id,
result: { mode: "company-config" },
});
const secretRequest = await nextMessage();
expect(secretRequest).toMatchObject({
method: "secrets.resolve",
params: {
secretRef: "77777777-7777-4777-8777-777777777777",
companyId: "company-1",
},
});
writeMessage(stdin, {
jsonrpc: "2.0",
id: secretRequest.id,
result: "company-secret",
});
await expect(nextMessage()).resolves.toMatchObject({
id: 2,
result: { content: "company-config:company-secret" },
});
host.stop();
const configRequest = await nextMessage();
expect(configRequest).toMatchObject({
method: "config.get",
params: { companyId: "company-1" },
paperclipInvocationId: "invocation-session",
});
writeMessage(stdin, {
jsonrpc: "2.0",
id: configRequest.id,
result: { mode: "company-config" },
});
} finally {
host.stop();
stdin.end();
stdout.destroy();
}
});
});

View file

@ -0,0 +1,129 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { agentTaskSessions, agents, companies, createDb } from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
let liveEventListener: ((event: {
type: string;
payload?: Record<string, unknown>;
}) => void) | null = null;
vi.mock("../services/heartbeat.js", () => ({
heartbeatService: () => ({
wakeup: vi.fn(async () => ({ id: "run-1" })),
}),
}));
vi.mock("../services/live-events.js", () => ({
subscribeCompanyLiveEvents: vi.fn((_companyId: string, listener: typeof liveEventListener) => {
liveEventListener = listener;
return () => {
liveEventListener = null;
};
}),
}));
const { buildHostServices } = await import("../services/plugin-host-services.js");
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describeEmbeddedPostgres("plugin host services session events", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-host-services-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
liveEventListener = null;
await db.delete(agentTaskSessions);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
function createEventBusStub() {
return {
forPlugin() {
return {
emit: async () => {},
subscribe: () => {},
clear: () => {},
};
},
} as any;
}
async function seedCompanyAndAgent() {
const companyId = randomUUID();
const agentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Engineer",
role: "engineer",
status: "idle",
adapterType: "process",
adapterConfig: { command: "true" },
runtimeConfig: {},
permissions: {},
});
return { companyId, agentId };
}
it("forwards companyId on live agent session notifications", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const notifyWorker = vi.fn();
const services = buildHostServices(
db,
"plugin-record-id",
"paperclip.missions",
createEventBusStub(),
notifyWorker,
);
const session = await services.agentSessions.create({ agentId, companyId });
const sendResult = await services.agentSessions.sendMessage({
sessionId: session.sessionId,
companyId,
prompt: "hello",
});
liveEventListener?.({
type: "heartbeat.run.log",
payload: {
runId: sendResult.runId,
seq: 1,
stream: "stdout",
chunk: "hello from run",
},
});
expect(notifyWorker).toHaveBeenCalledWith(
"agents.sessions.event",
expect.objectContaining({
sessionId: session.sessionId,
runId: sendResult.runId,
companyId,
eventType: "chunk",
}),
);
services.dispose();
});
});

View file

@ -86,7 +86,9 @@ async function createApp(
next();
});
app.use("/api", pluginRoutes(
(routeOverrides.db ?? {}) as never,
(routeOverrides.db ?? {
transaction: async <T>(callback: (tx: unknown) => Promise<T>) => callback({}),
}) as never,
loader as never,
routeOverrides.jobDeps as never,
undefined,
@ -363,7 +365,9 @@ describe.sequential("plugin install and upgrade authz", () => {
const { app } = await createApp(boardActor({
isInstanceAdmin: true,
companyIds: [companyA],
}));
}), {}, {
bridgeDeps: { workerManager: mockWorkerManager },
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/config`)
@ -382,6 +386,7 @@ describe.sequential("plugin install and upgrade authz", () => {
secretId: "77777777-7777-4777-8777-777777777777",
configPath: "$",
}],
{ db: {} },
);
expect(mockRegistry.upsertConfig).toHaveBeenCalledWith(
pluginId,
@ -392,6 +397,7 @@ describe.sequential("plugin install and upgrade authz", () => {
},
companyA,
);
expect(mockWorkerManager.call).not.toHaveBeenCalled();
}, 20_000);
it("rejects company-scoped plugin config secret refs that do not belong to the selected company", async () => {

View file

@ -79,6 +79,8 @@ import {
import { secretService } from "../services/secrets.js";
import { badRequest, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
/** UI slot declaration extracted from plugin manifest */
type PluginUiSlotDeclaration = NonNullable<NonNullable<PaperclipPluginManifestV1["ui"]>["slots"]>[number];
/** Launcher declaration extracted from plugin manifest */
@ -691,6 +693,30 @@ export function pluginRoutes(
return null;
}
async function upsertPluginConfigWithSecretRefs(
pluginId: string,
configJson: Record<string, unknown>,
companyId: string | null,
secretRefsByPath: Map<string, Set<string>>,
) {
return db.transaction(async (tx) => {
const txRegistry = pluginRegistryService(tx as unknown as Db);
if (companyId) {
const refs = [...secretRefsByPath.entries()].flatMap(([secretId, paths]) =>
[...paths].map((configPath) => ({ secretId, configPath })),
);
await secrets.syncSecretRefsForTarget(
companyId,
{ targetType: "plugin", targetId: pluginId },
refs,
{ db: tx as DbTransaction },
);
}
return txRegistry.upsertConfig(pluginId, { configJson }, companyId);
});
}
function assertPluginBridgeScope(req: Request, companyId: unknown): string | undefined {
if (companyId === undefined || companyId === null) {
assertInstanceAdmin(req);
@ -2201,20 +2227,12 @@ export function pluginRoutes(
res.status(422).json({ error: "Plugin secret references require companyId" });
return;
}
if (companyId) {
const refs = [...secretRefsByPath.entries()].flatMap(([secretId, paths]) =>
[...paths].map((configPath) => ({ secretId, configPath })),
);
await secrets.syncSecretRefsForTarget(
companyId,
{ targetType: "plugin", targetId: plugin.id },
refs,
);
}
const result = await registry.upsertConfig(plugin.id, {
configJson: body.configJson,
}, companyId);
const result = await upsertPluginConfigWithSecretRefs(
plugin.id,
body.configJson,
companyId,
secretRefsByPath,
);
await logPluginMutationActivity(req, "plugin.config.updated", plugin.id, {
pluginId: plugin.id,
pluginKey: plugin.pluginKey,

View file

@ -2647,6 +2647,7 @@ export function buildHostServices(
notifyWorker("agents.sessions.event", {
sessionId: params.sessionId,
runId: run.id,
companyId,
seq: (payload.seq as number) ?? 0,
eventType: "chunk",
stream: (payload.stream as string) ?? null,
@ -2659,6 +2660,7 @@ export function buildHostServices(
notifyWorker("agents.sessions.event", {
sessionId: params.sessionId,
runId: run.id,
companyId,
seq: 0,
eventType: status === "succeeded" ? "done" : "error",
stream: "system",
@ -2670,6 +2672,7 @@ export function buildHostServices(
notifyWorker("agents.sessions.event", {
sessionId: params.sessionId,
runId: run.id,
companyId,
seq: 0,
eventType: "status",
stream: "system",

View file

@ -1,4 +1,4 @@
import { asc, eq, ne, sql, and, isNull } from "drizzle-orm";
import { asc, eq, ne, sql, and, isNull, or } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
plugins,
@ -44,13 +44,27 @@ function isPluginKeyConflict(error: unknown): boolean {
return err.code === "23505" && constraint === "plugins_plugin_key_idx";
}
function pluginConfigScopeCondition(pluginId: string, companyId?: string | null) {
function pluginConfigExactScopeCondition(pluginId: string, companyId?: string | null) {
return and(
eq(pluginConfig.pluginId, pluginId),
companyId ? eq(pluginConfig.companyId, companyId) : isNull(pluginConfig.companyId),
);
}
function pluginConfigReadScopeCondition(pluginId: string, companyId?: string | null) {
if (!companyId) {
return pluginConfigExactScopeCondition(pluginId, null);
}
return and(
eq(pluginConfig.pluginId, pluginId),
or(
eq(pluginConfig.companyId, companyId),
isNull(pluginConfig.companyId),
),
);
}
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
@ -292,7 +306,12 @@ export function pluginRegistryService(db: Db) {
db
.select()
.from(pluginConfig)
.where(pluginConfigScopeCondition(pluginId, companyId))
.where(pluginConfigReadScopeCondition(pluginId, companyId))
.orderBy(
companyId
? sql`case when ${pluginConfig.companyId} = ${companyId} then 0 else 1 end`
: sql`0`,
)
.then((rows) => rows[0] ?? null),
/**
@ -307,7 +326,7 @@ export function pluginRegistryService(db: Db) {
const existing = await db
.select()
.from(pluginConfig)
.where(pluginConfigScopeCondition(pluginId, companyId))
.where(pluginConfigExactScopeCondition(pluginId, companyId))
.then((rows) => rows[0] ?? null);
if (existing) {
@ -318,7 +337,7 @@ export function pluginRegistryService(db: Db) {
lastError: null,
updatedAt: new Date(),
})
.where(pluginConfigScopeCondition(pluginId, companyId))
.where(pluginConfigExactScopeCondition(pluginId, companyId))
.returning()
.then((rows) => rows[0]);
}
@ -345,7 +364,7 @@ export function pluginRegistryService(db: Db) {
const existing = await db
.select()
.from(pluginConfig)
.where(pluginConfigScopeCondition(pluginId, companyId))
.where(pluginConfigExactScopeCondition(pluginId, companyId))
.then((rows) => rows[0] ?? null);
if (existing) {
@ -357,7 +376,7 @@ export function pluginRegistryService(db: Db) {
lastError: null,
updatedAt: new Date(),
})
.where(pluginConfigScopeCondition(pluginId, companyId))
.where(pluginConfigExactScopeCondition(pluginId, companyId))
.returning()
.then((rows) => rows[0]);
}
@ -381,7 +400,7 @@ export function pluginRegistryService(db: Db) {
const rows = await db
.update(pluginConfig)
.set({ lastError, updatedAt: new Date() })
.where(pluginConfigScopeCondition(pluginId, companyId))
.where(pluginConfigExactScopeCondition(pluginId, companyId))
.returning();
if (rows.length === 0) throw notFound("Plugin config not found");
@ -392,7 +411,7 @@ export function pluginRegistryService(db: Db) {
deleteConfig: async (pluginId: string, companyId?: string | null) => {
const rows = await db
.delete(pluginConfig)
.where(pluginConfigScopeCondition(pluginId, companyId))
.where(pluginConfigExactScopeCondition(pluginId, companyId))
.returning();
return rows[0] ?? null;