From 8969a713a1f7d73b58e7b60a2e968ffdbd4da7e1 Mon Sep 17 00:00:00 2001 From: Paperclip Bot Date: Fri, 5 Jun 2026 08:13:45 +0000 Subject: [PATCH 1/5] Scope plugin config bridge reads to invocation company --- .../plugins/sdk/src/host-client-factory.ts | 12 +++++- .../__tests__/plugin-config-bridge.test.ts | 42 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 server/src/__tests__/plugin-config-bridge.test.ts diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts index ec58bf4e..94d0e1fa 100644 --- a/packages/plugins/sdk/src/host-client-factory.ts +++ b/packages/plugins/sdk/src/host-client-factory.ts @@ -627,8 +627,16 @@ export function createHostClientHandlers( return { // Config - "config.get": gated("config.get", async (params) => { - return services.config.get(params); + "config.get": gated("config.get", async (params, context) => { + const scopedCompanyId = readNonEmptyString(context?.invocationScope?.companyId); + const explicitCompanyId = Object.prototype.hasOwnProperty.call(params ?? {}, "companyId") + ? params.companyId ?? null + : undefined; + return services.config.get( + explicitCompanyId === undefined + ? (scopedCompanyId ? { companyId: scopedCompanyId } : {}) + : { companyId: explicitCompanyId }, + ); }), "localFolders.declarations": gated("localFolders.declarations", async (params) => { diff --git a/server/src/__tests__/plugin-config-bridge.test.ts b/server/src/__tests__/plugin-config-bridge.test.ts new file mode 100644 index 00000000..57d0a1e1 --- /dev/null +++ b/server/src/__tests__/plugin-config-bridge.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; +import { createHostClientHandlers } from "../../../packages/plugins/sdk/src/host-client-factory.js"; + +describe("plugin config bridge scoping", () => { + it("falls back to the invocation company scope for config.get when the worker omits companyId", async () => { + const getConfig = vi.fn(async (params: { companyId?: string | null }) => ({ + scope: params.companyId ?? null, + })); + + const handlers = createHostClientHandlers({ + pluginId: "test.plugin", + capabilities: [], + services: { + config: { get: getConfig }, + } as never, + }); + + await expect( + handlers["config.get"]({}, { invocationScope: { companyId: "company-a" } }), + ).resolves.toEqual({ scope: "company-a" }); + expect(getConfig).toHaveBeenCalledWith({ companyId: "company-a" }); + }); + + it("preserves an explicit global config.get request even inside a company-scoped invocation", async () => { + const getConfig = vi.fn(async (params: { companyId?: string | null }) => ({ + scope: params.companyId ?? null, + })); + + const handlers = createHostClientHandlers({ + pluginId: "test.plugin", + capabilities: [], + services: { + config: { get: getConfig }, + } as never, + }); + + await expect( + handlers["config.get"]({ companyId: null }, { invocationScope: { companyId: "company-a" } }), + ).resolves.toEqual({ scope: null }); + expect(getConfig).toHaveBeenCalledWith({ companyId: null }); + }); +}); From 1b3e1e3745f60d001045d53aacc229fbfaea2bb4 Mon Sep 17 00:00:00 2001 From: Paperclip Bot Date: Fri, 5 Jun 2026 08:21:17 +0000 Subject: [PATCH 2/5] fix(plugin): stop secret auth fallback to global config --- .../plugin-secrets-handler-runtime.test.ts | 30 ++++++++++++++----- server/src/services/plugin-registry.ts | 8 +++++ server/src/services/plugin-secrets-handler.ts | 5 +++- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/server/src/__tests__/plugin-secrets-handler-runtime.test.ts b/server/src/__tests__/plugin-secrets-handler-runtime.test.ts index da152656..504ad9e0 100644 --- a/server/src/__tests__/plugin-secrets-handler-runtime.test.ts +++ b/server/src/__tests__/plugin-secrets-handler-runtime.test.ts @@ -1,13 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ - getConfig: vi.fn(), + getConfigExactScope: vi.fn(), resolveSecretValue: vi.fn(), })); vi.mock("../services/plugin-registry.js", () => ({ pluginRegistryService: () => ({ - getConfig: mocks.getConfig, + getConfigExactScope: mocks.getConfigExactScope, }), })); @@ -40,7 +40,7 @@ const manifest = { describe("createPluginSecretsHandler runtime company scoping", () => { beforeEach(() => { - mocks.getConfig.mockReset(); + mocks.getConfigExactScope.mockReset(); mocks.resolveSecretValue.mockReset(); }); @@ -54,12 +54,12 @@ describe("createPluginSecretsHandler runtime company scoping", () => { await expect(handler.resolve({ secretRef })).rejects.toThrow( PLUGIN_SECRET_REFS_REQUIRE_COMPANY_MESSAGE, ); - expect(mocks.getConfig).not.toHaveBeenCalled(); + expect(mocks.getConfigExactScope).not.toHaveBeenCalled(); expect(mocks.resolveSecretValue).not.toHaveBeenCalled(); }); it("rejects a secret ref that is not referenced by that company's plugin config", async () => { - mocks.getConfig.mockResolvedValue({ + mocks.getConfigExactScope.mockResolvedValue({ configJson: { apiKeyRef: "88888888-8888-4888-8888-888888888888", }, @@ -74,12 +74,12 @@ describe("createPluginSecretsHandler runtime company scoping", () => { await expect(handler.resolve({ secretRef, companyId })).rejects.toThrow( /not referenced by this company's plugin config/i, ); - expect(mocks.getConfig).toHaveBeenCalledWith(pluginId, companyId); + expect(mocks.getConfigExactScope).toHaveBeenCalledWith(pluginId, companyId); expect(mocks.resolveSecretValue).not.toHaveBeenCalled(); }); it("resolves only through the company plugin binding context from saved config", async () => { - mocks.getConfig.mockResolvedValue({ + mocks.getConfigExactScope.mockResolvedValue({ configJson: { apiKeyRef: secretRef, }, @@ -102,4 +102,20 @@ describe("createPluginSecretsHandler runtime company scoping", () => { pluginId, }); }); + + it("does not authorize a company-scoped secret resolution from legacy global config fallback", async () => { + mocks.getConfigExactScope.mockResolvedValue(null); + + const handler = createPluginSecretsHandler({ + db: {} as never, + pluginId, + manifest: manifest as never, + }); + + await expect(handler.resolve({ secretRef, companyId })).rejects.toThrow( + /not referenced by this company's plugin config/i, + ); + expect(mocks.getConfigExactScope).toHaveBeenCalledWith(pluginId, companyId); + expect(mocks.resolveSecretValue).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/services/plugin-registry.ts b/server/src/services/plugin-registry.ts index 355717e3..c80c0cf4 100644 --- a/server/src/services/plugin-registry.ts +++ b/server/src/services/plugin-registry.ts @@ -287,6 +287,14 @@ export function pluginRegistryService(db: Db) { // ----- Config --------------------------------------------------------- + /** Retrieve a plugin's config at the exact requested scope. */ + getConfigExactScope: (pluginId: string, companyId?: string | null) => + db + .select() + .from(pluginConfig) + .where(pluginConfigExactScopeCondition(pluginId, companyId)) + .then((rows) => rows[0] ?? null), + /** Retrieve a plugin's company-scoped config, or the legacy global fallback. */ getConfig: async (pluginId: string, companyId?: string | null) => { if (companyId) { diff --git a/server/src/services/plugin-secrets-handler.ts b/server/src/services/plugin-secrets-handler.ts index cadb33c7..84d370d0 100644 --- a/server/src/services/plugin-secrets-handler.ts +++ b/server/src/services/plugin-secrets-handler.ts @@ -242,7 +242,10 @@ export function createPluginSecretsHandler( throw new Error(PLUGIN_SECRET_REFS_REQUIRE_COMPANY_MESSAGE); } - const configRow = await registry.getConfig(pluginId, companyId); + // Authorize secret refs only from the exact company-scoped config row. + // Falling back to legacy/global config would let a live worker keep using + // stale global refs after a company-scoped invocation takes over. + const configRow = await registry.getConfigExactScope(pluginId, companyId); const refsBySecret = extractSecretRefPathsFromConfig( configRow?.configJson ?? {}, manifest?.instanceConfigSchema, From fad323759570544f6d036e3f0f1a8d929484cbf0 Mon Sep 17 00:00:00 2001 From: Paperclip Bot Date: Fri, 5 Jun 2026 08:28:22 +0000 Subject: [PATCH 3/5] Fix scoped plugin config reads for live workers --- .../plugin-host-services-config-scope.test.ts | 76 +++++++++++++++++++ server/src/services/plugin-host-services.ts | 5 +- 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 server/src/__tests__/plugin-host-services-config-scope.test.ts diff --git a/server/src/__tests__/plugin-host-services-config-scope.test.ts b/server/src/__tests__/plugin-host-services-config-scope.test.ts new file mode 100644 index 00000000..28518b4f --- /dev/null +++ b/server/src/__tests__/plugin-host-services-config-scope.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getConfig: vi.fn(), + getConfigExactScope: vi.fn(), +})); + +vi.mock("../services/plugin-registry.js", () => ({ + pluginRegistryService: () => ({ + getConfig: mocks.getConfig, + getConfigExactScope: mocks.getConfigExactScope, + }), +})); + +import { buildHostServices } from "../services/plugin-host-services.js"; + +function createEventBusStub() { + return { + forPlugin() { + return { + emit: vi.fn(), + subscribe: vi.fn(), + clear: vi.fn(), + }; + }, + } as any; +} + +describe("plugin host services config scoping", () => { + beforeEach(() => { + mocks.getConfig.mockReset(); + mocks.getConfigExactScope.mockReset(); + }); + + it("does not fall back to legacy global config for company-scoped reads", async () => { + mocks.getConfigExactScope.mockResolvedValue(null); + mocks.getConfig.mockResolvedValue({ + configJson: { tokenRef: "global-token-ref" }, + }); + + const services = buildHostServices( + {} as never, + "plugin-record-id", + "paperclip.example", + createEventBusStub(), + ); + + await expect( + services.config.get({ companyId: "company-a" }), + ).resolves.toEqual({}); + expect(mocks.getConfigExactScope).toHaveBeenCalledWith("plugin-record-id", "company-a"); + expect(mocks.getConfig).not.toHaveBeenCalled(); + + services.dispose(); + }); + + it("still reads the exact global row for explicit global requests", async () => { + mocks.getConfigExactScope.mockResolvedValue({ + configJson: { tokenRef: "global-token-ref" }, + }); + + const services = buildHostServices( + {} as never, + "plugin-record-id", + "paperclip.example", + createEventBusStub(), + ); + + await expect( + services.config.get({ companyId: null }), + ).resolves.toEqual({ tokenRef: "global-token-ref" }); + expect(mocks.getConfigExactScope).toHaveBeenCalledWith("plugin-record-id", null); + + services.dispose(); + }); +}); diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index 4ea90454..e76151cf 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -1054,7 +1054,10 @@ export function buildHostServices( return { config: { async get(params) { - const configRow = await registry.getConfig(pluginId, params?.companyId ?? null); + const companyId = Object.prototype.hasOwnProperty.call(params ?? {}, "companyId") + ? params.companyId ?? null + : null; + const configRow = await registry.getConfigExactScope(pluginId, companyId); return configRow?.configJson ?? {}; }, }, From 0a56b88fa75ccc544642c331f924d513ec8fb950 Mon Sep 17 00:00:00 2001 From: Paperclip Bot Date: Fri, 5 Jun 2026 08:33:23 +0000 Subject: [PATCH 4/5] Fix plugin job company scope propagation --- packages/plugins/sdk/src/worker-rpc-host.ts | 5 +- .../plugins/sdk/tests/worker-rpc-host.test.ts | 91 +++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index 28573112..5bec10e2 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -1502,7 +1502,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost if (!handler) { throw new Error(`No handler registered for job "${params.job.jobKey}"`); } - await handler(params.job); + await runtimeCompanyContext.run( + { companyId: params.job.companyId ?? null }, + () => handler(params.job), + ); } async function handleWebhook(params: PluginWebhookInput): Promise { diff --git a/packages/plugins/sdk/tests/worker-rpc-host.test.ts b/packages/plugins/sdk/tests/worker-rpc-host.test.ts index ef1eead9..fcbeb657 100644 --- a/packages/plugins/sdk/tests/worker-rpc-host.test.ts +++ b/packages/plugins/sdk/tests/worker-rpc-host.test.ts @@ -514,4 +514,95 @@ describe("startWorkerRpcHost runtime company context", () => { stdout.destroy(); } }); + + it("passes runJob 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.jobs.register("check-job-context", async () => { + const config = await ctx.config.get(); + await ctx.state.set( + { + scopeKind: "instance", + namespace: "job-context", + stateKey: "mode", + }, + config.mode, + ); + }); + }, + }); + + 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: "runJob", + params: { + job: { + id: "job-1", + jobKey: "check-job-context", + companyId: "company-1", + payload: {}, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }, + }); + + 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 stateRequest = await nextMessage(); + expect(stateRequest).toMatchObject({ + method: "state.set", + params: { + scopeKind: "instance", + namespace: "job-context", + stateKey: "mode", + value: "company-config", + }, + }); + writeMessage(stdin, { + jsonrpc: "2.0", + id: stateRequest.id, + result: null, + }); + + await expect(nextMessage()).resolves.toMatchObject({ + id: 2, + result: null, + }); + } finally { + host.stop(); + stdin.destroy(); + stdout.destroy(); + } + }); }); From ce18c1a9e2e868c3032a0bdffb8bbf13095c9f2d Mon Sep 17 00:00:00 2001 From: Paperclip Bot Date: Fri, 5 Jun 2026 10:42:01 +0000 Subject: [PATCH 5/5] Remove unsupported job company scope patch --- packages/plugins/sdk/src/worker-rpc-host.ts | 5 +- .../plugins/sdk/tests/worker-rpc-host.test.ts | 90 ------------------- 2 files changed, 1 insertion(+), 94 deletions(-) diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index 5bec10e2..28573112 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -1502,10 +1502,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost if (!handler) { throw new Error(`No handler registered for job "${params.job.jobKey}"`); } - await runtimeCompanyContext.run( - { companyId: params.job.companyId ?? null }, - () => handler(params.job), - ); + await handler(params.job); } async function handleWebhook(params: PluginWebhookInput): Promise { diff --git a/packages/plugins/sdk/tests/worker-rpc-host.test.ts b/packages/plugins/sdk/tests/worker-rpc-host.test.ts index fcbeb657..e4f0b2ce 100644 --- a/packages/plugins/sdk/tests/worker-rpc-host.test.ts +++ b/packages/plugins/sdk/tests/worker-rpc-host.test.ts @@ -515,94 +515,4 @@ describe("startWorkerRpcHost runtime company context", () => { } }); - it("passes runJob 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.jobs.register("check-job-context", async () => { - const config = await ctx.config.get(); - await ctx.state.set( - { - scopeKind: "instance", - namespace: "job-context", - stateKey: "mode", - }, - config.mode, - ); - }); - }, - }); - - 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: "runJob", - params: { - job: { - id: "job-1", - jobKey: "check-job-context", - companyId: "company-1", - payload: {}, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - }, - }); - - 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 stateRequest = await nextMessage(); - expect(stateRequest).toMatchObject({ - method: "state.set", - params: { - scopeKind: "instance", - namespace: "job-context", - stateKey: "mode", - value: "company-config", - }, - }); - writeMessage(stdin, { - jsonrpc: "2.0", - id: stateRequest.id, - result: null, - }); - - await expect(nextMessage()).resolves.toMatchObject({ - id: 2, - result: null, - }); - } finally { - host.stop(); - stdin.destroy(); - stdout.destroy(); - } - }); });