mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 19:20:39 +09:00
Fix plugin company-scoped config resolution
This commit is contained in:
parent
5317029ef4
commit
0509d08f3c
8 changed files with 505 additions and 84 deletions
|
|
@ -1510,6 +1510,7 @@ export interface AgentSession {
|
||||||
export interface AgentSessionEvent {
|
export interface AgentSessionEvent {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
runId: string;
|
runId: string;
|
||||||
|
companyId?: string | null;
|
||||||
seq: number;
|
seq: number;
|
||||||
/** The kind of event: "chunk" for output data, "status" for run state changes, "done" for end-of-stream, "error" for failures. */
|
/** 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";
|
eventType: "chunk" | "status" | "done" | "error";
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,16 @@ interface RuntimeCompanyContext {
|
||||||
companyId?: string | null;
|
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
|
// Internal: event registration
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -411,6 +421,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
function buildContext(): PluginContext {
|
function buildContext(): PluginContext {
|
||||||
|
const currentCompanyId = () =>
|
||||||
|
runtimeCompanyContext.getStore()?.companyId ??
|
||||||
|
invocationContextStorage.getStore()?.scope.companyId ??
|
||||||
|
null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get manifest() {
|
get manifest() {
|
||||||
if (!manifest) throw new Error("Plugin context accessed before initialization");
|
if (!manifest) throw new Error("Plugin context accessed before initialization");
|
||||||
|
|
@ -419,9 +434,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||||
|
|
||||||
config: {
|
config: {
|
||||||
async get(params) {
|
async get(params) {
|
||||||
const companyId =
|
return callHost("config.get", runtimeCompanyParams(
|
||||||
params?.companyId ?? runtimeCompanyContext.getStore()?.companyId ?? null;
|
params,
|
||||||
return callHost("config.get", companyId ? { companyId } : {});
|
currentCompanyId(),
|
||||||
|
));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -572,8 +588,16 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||||
|
|
||||||
secrets: {
|
secrets: {
|
||||||
async resolve(secretRef: string, companyId?: string | null): Promise<string> {
|
async resolve(secretRef: string, companyId?: string | null): Promise<string> {
|
||||||
const scopedCompanyId = companyId ?? runtimeCompanyContext.getStore()?.companyId ?? null;
|
const providedCompanyParams = arguments.length > 1
|
||||||
return callHost("secrets.resolve", { secretRef, companyId: scopedCompanyId });
|
? { 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) {
|
if (notif.method === "agents.sessions.event" && notif.params) {
|
||||||
const event = notif.params as AgentSessionEvent;
|
const event = notif.params as AgentSessionEvent;
|
||||||
const cb = sessionEventCallbacks.get(event.sessionId);
|
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) {
|
} else if (notif.method === "onEvent" && notif.params) {
|
||||||
// Plugin event bus notifications — dispatch to registered event handlers
|
// Plugin event bus notifications — dispatch to registered event handlers
|
||||||
Promise.resolve(runNotification(() => handleOnEvent(notif.params as OnEventParams))).catch((err) => {
|
Promise.resolve(runNotification(() => handleOnEvent(notif.params as OnEventParams))).catch((err) => {
|
||||||
|
|
|
||||||
|
|
@ -354,66 +354,273 @@ describe("startWorkerRpcHost runtime company context", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const host = startWorkerRpcHost({ plugin, stdin, stdout });
|
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, {
|
writeMessage(stdin, {
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
id: 1,
|
id: 2,
|
||||||
method: "initialize",
|
method: "executeTool",
|
||||||
params: {
|
params: {
|
||||||
manifest: { id: "test-plugin", name: "test-plugin", version: "1.0.0" },
|
toolName: "check-context",
|
||||||
config: {},
|
parameters: {},
|
||||||
instanceInfo: { instanceId: "inst-1", hostVersion: "0.0.0-test" },
|
runContext: {
|
||||||
apiVersion: 1,
|
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, {
|
const host = startWorkerRpcHost({ plugin, stdin, stdout });
|
||||||
jsonrpc: "2.0",
|
try {
|
||||||
id: 2,
|
writeMessage(stdin, {
|
||||||
method: "executeTool",
|
jsonrpc: "2.0",
|
||||||
params: {
|
id: 1,
|
||||||
toolName: "check-context",
|
method: "initialize",
|
||||||
parameters: {},
|
params: {
|
||||||
runContext: {
|
manifest: { id: "test-plugin", name: "test-plugin", version: "1.0.0" },
|
||||||
agentId: "agent-1",
|
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",
|
runId: "run-1",
|
||||||
companyId: "company-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();
|
const configRequest = await nextMessage();
|
||||||
expect(configRequest).toMatchObject({
|
expect(configRequest).toMatchObject({
|
||||||
method: "config.get",
|
method: "config.get",
|
||||||
params: { companyId: "company-1" },
|
params: { companyId: "company-1" },
|
||||||
});
|
paperclipInvocationId: "invocation-session",
|
||||||
writeMessage(stdin, {
|
});
|
||||||
jsonrpc: "2.0",
|
writeMessage(stdin, {
|
||||||
id: configRequest.id,
|
jsonrpc: "2.0",
|
||||||
result: { mode: "company-config" },
|
id: configRequest.id,
|
||||||
});
|
result: { mode: "company-config" },
|
||||||
|
});
|
||||||
const secretRequest = await nextMessage();
|
} finally {
|
||||||
expect(secretRequest).toMatchObject({
|
host.stop();
|
||||||
method: "secrets.resolve",
|
stdin.end();
|
||||||
params: {
|
stdout.destroy();
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
129
server/src/__tests__/plugin-host-services-session-events.test.ts
Normal file
129
server/src/__tests__/plugin-host-services-session-events.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -86,7 +86,9 @@ async function createApp(
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
app.use("/api", pluginRoutes(
|
app.use("/api", pluginRoutes(
|
||||||
(routeOverrides.db ?? {}) as never,
|
(routeOverrides.db ?? {
|
||||||
|
transaction: async <T>(callback: (tx: unknown) => Promise<T>) => callback({}),
|
||||||
|
}) as never,
|
||||||
loader as never,
|
loader as never,
|
||||||
routeOverrides.jobDeps as never,
|
routeOverrides.jobDeps as never,
|
||||||
undefined,
|
undefined,
|
||||||
|
|
@ -363,7 +365,9 @@ describe.sequential("plugin install and upgrade authz", () => {
|
||||||
const { app } = await createApp(boardActor({
|
const { app } = await createApp(boardActor({
|
||||||
isInstanceAdmin: true,
|
isInstanceAdmin: true,
|
||||||
companyIds: [companyA],
|
companyIds: [companyA],
|
||||||
}));
|
}), {}, {
|
||||||
|
bridgeDeps: { workerManager: mockWorkerManager },
|
||||||
|
});
|
||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post(`/api/plugins/${pluginId}/config`)
|
.post(`/api/plugins/${pluginId}/config`)
|
||||||
|
|
@ -382,6 +386,7 @@ describe.sequential("plugin install and upgrade authz", () => {
|
||||||
secretId: "77777777-7777-4777-8777-777777777777",
|
secretId: "77777777-7777-4777-8777-777777777777",
|
||||||
configPath: "$",
|
configPath: "$",
|
||||||
}],
|
}],
|
||||||
|
{ db: {} },
|
||||||
);
|
);
|
||||||
expect(mockRegistry.upsertConfig).toHaveBeenCalledWith(
|
expect(mockRegistry.upsertConfig).toHaveBeenCalledWith(
|
||||||
pluginId,
|
pluginId,
|
||||||
|
|
@ -392,6 +397,7 @@ describe.sequential("plugin install and upgrade authz", () => {
|
||||||
},
|
},
|
||||||
companyA,
|
companyA,
|
||||||
);
|
);
|
||||||
|
expect(mockWorkerManager.call).not.toHaveBeenCalled();
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
it("rejects company-scoped plugin config secret refs that do not belong to the selected company", async () => {
|
it("rejects company-scoped plugin config secret refs that do not belong to the selected company", async () => {
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,8 @@ import {
|
||||||
import { secretService } from "../services/secrets.js";
|
import { secretService } from "../services/secrets.js";
|
||||||
import { badRequest, forbidden, notFound, unauthorized, unprocessable } from "../errors.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 */
|
/** UI slot declaration extracted from plugin manifest */
|
||||||
type PluginUiSlotDeclaration = NonNullable<NonNullable<PaperclipPluginManifestV1["ui"]>["slots"]>[number];
|
type PluginUiSlotDeclaration = NonNullable<NonNullable<PaperclipPluginManifestV1["ui"]>["slots"]>[number];
|
||||||
/** Launcher declaration extracted from plugin manifest */
|
/** Launcher declaration extracted from plugin manifest */
|
||||||
|
|
@ -691,6 +693,30 @@ export function pluginRoutes(
|
||||||
return null;
|
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 {
|
function assertPluginBridgeScope(req: Request, companyId: unknown): string | undefined {
|
||||||
if (companyId === undefined || companyId === null) {
|
if (companyId === undefined || companyId === null) {
|
||||||
assertInstanceAdmin(req);
|
assertInstanceAdmin(req);
|
||||||
|
|
@ -2201,20 +2227,12 @@ export function pluginRoutes(
|
||||||
res.status(422).json({ error: "Plugin secret references require companyId" });
|
res.status(422).json({ error: "Plugin secret references require companyId" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (companyId) {
|
const result = await upsertPluginConfigWithSecretRefs(
|
||||||
const refs = [...secretRefsByPath.entries()].flatMap(([secretId, paths]) =>
|
plugin.id,
|
||||||
[...paths].map((configPath) => ({ secretId, configPath })),
|
body.configJson,
|
||||||
);
|
companyId,
|
||||||
await secrets.syncSecretRefsForTarget(
|
secretRefsByPath,
|
||||||
companyId,
|
);
|
||||||
{ targetType: "plugin", targetId: plugin.id },
|
|
||||||
refs,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await registry.upsertConfig(plugin.id, {
|
|
||||||
configJson: body.configJson,
|
|
||||||
}, companyId);
|
|
||||||
await logPluginMutationActivity(req, "plugin.config.updated", plugin.id, {
|
await logPluginMutationActivity(req, "plugin.config.updated", plugin.id, {
|
||||||
pluginId: plugin.id,
|
pluginId: plugin.id,
|
||||||
pluginKey: plugin.pluginKey,
|
pluginKey: plugin.pluginKey,
|
||||||
|
|
|
||||||
|
|
@ -2647,6 +2647,7 @@ export function buildHostServices(
|
||||||
notifyWorker("agents.sessions.event", {
|
notifyWorker("agents.sessions.event", {
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
|
companyId,
|
||||||
seq: (payload.seq as number) ?? 0,
|
seq: (payload.seq as number) ?? 0,
|
||||||
eventType: "chunk",
|
eventType: "chunk",
|
||||||
stream: (payload.stream as string) ?? null,
|
stream: (payload.stream as string) ?? null,
|
||||||
|
|
@ -2659,6 +2660,7 @@ export function buildHostServices(
|
||||||
notifyWorker("agents.sessions.event", {
|
notifyWorker("agents.sessions.event", {
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
|
companyId,
|
||||||
seq: 0,
|
seq: 0,
|
||||||
eventType: status === "succeeded" ? "done" : "error",
|
eventType: status === "succeeded" ? "done" : "error",
|
||||||
stream: "system",
|
stream: "system",
|
||||||
|
|
@ -2670,6 +2672,7 @@ export function buildHostServices(
|
||||||
notifyWorker("agents.sessions.event", {
|
notifyWorker("agents.sessions.event", {
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
|
companyId,
|
||||||
seq: 0,
|
seq: 0,
|
||||||
eventType: "status",
|
eventType: "status",
|
||||||
stream: "system",
|
stream: "system",
|
||||||
|
|
|
||||||
|
|
@ -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 type { Db } from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
plugins,
|
plugins,
|
||||||
|
|
@ -44,13 +44,27 @@ function isPluginKeyConflict(error: unknown): boolean {
|
||||||
return err.code === "23505" && constraint === "plugins_plugin_key_idx";
|
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(
|
return and(
|
||||||
eq(pluginConfig.pluginId, pluginId),
|
eq(pluginConfig.pluginId, pluginId),
|
||||||
companyId ? eq(pluginConfig.companyId, companyId) : isNull(pluginConfig.companyId),
|
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
|
// Service
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -292,7 +306,12 @@ export function pluginRegistryService(db: Db) {
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(pluginConfig)
|
.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),
|
.then((rows) => rows[0] ?? null),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -307,7 +326,7 @@ export function pluginRegistryService(db: Db) {
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(pluginConfig)
|
.from(pluginConfig)
|
||||||
.where(pluginConfigScopeCondition(pluginId, companyId))
|
.where(pluginConfigExactScopeCondition(pluginId, companyId))
|
||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|
@ -318,7 +337,7 @@ export function pluginRegistryService(db: Db) {
|
||||||
lastError: null,
|
lastError: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(pluginConfigScopeCondition(pluginId, companyId))
|
.where(pluginConfigExactScopeCondition(pluginId, companyId))
|
||||||
.returning()
|
.returning()
|
||||||
.then((rows) => rows[0]);
|
.then((rows) => rows[0]);
|
||||||
}
|
}
|
||||||
|
|
@ -345,7 +364,7 @@ export function pluginRegistryService(db: Db) {
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(pluginConfig)
|
.from(pluginConfig)
|
||||||
.where(pluginConfigScopeCondition(pluginId, companyId))
|
.where(pluginConfigExactScopeCondition(pluginId, companyId))
|
||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|
@ -357,7 +376,7 @@ export function pluginRegistryService(db: Db) {
|
||||||
lastError: null,
|
lastError: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(pluginConfigScopeCondition(pluginId, companyId))
|
.where(pluginConfigExactScopeCondition(pluginId, companyId))
|
||||||
.returning()
|
.returning()
|
||||||
.then((rows) => rows[0]);
|
.then((rows) => rows[0]);
|
||||||
}
|
}
|
||||||
|
|
@ -381,7 +400,7 @@ export function pluginRegistryService(db: Db) {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.update(pluginConfig)
|
.update(pluginConfig)
|
||||||
.set({ lastError, updatedAt: new Date() })
|
.set({ lastError, updatedAt: new Date() })
|
||||||
.where(pluginConfigScopeCondition(pluginId, companyId))
|
.where(pluginConfigExactScopeCondition(pluginId, companyId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (rows.length === 0) throw notFound("Plugin config not found");
|
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) => {
|
deleteConfig: async (pluginId: string, companyId?: string | null) => {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.delete(pluginConfig)
|
.delete(pluginConfig)
|
||||||
.where(pluginConfigScopeCondition(pluginId, companyId))
|
.where(pluginConfigExactScopeCondition(pluginId, companyId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue