diff --git a/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs b/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs index 83ed079a02..610609819e 100644 --- a/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs +++ b/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs @@ -22,6 +22,16 @@ function sendNestedHostRequest(originalRequest, invocationId) { }, configPath: params.configPath || "apiKeyRef", } + : hostMethod === "state.get" + ? { + // Company-scoped state key — the shape a proactive gateway loop uses + // (ctx.state.get with scopeKind "company"). The host derives the + // requested company from scopeId, not companyId. + scopeKind: "company", + scopeId: requestedCompanyId, + namespace: params.namespace || "ns", + stateKey: params.stateKey || "key", + } : { companyId: requestedCompanyId, }; diff --git a/server/src/__tests__/plugin-worker-manager.test.ts b/server/src/__tests__/plugin-worker-manager.test.ts index 9f2557eb46..0100af5f08 100644 --- a/server/src/__tests__/plugin-worker-manager.test.ts +++ b/server/src/__tests__/plugin-worker-manager.test.ts @@ -518,3 +518,124 @@ describe("plugin host company context guards", () => { } }); }); + + +describe("plugin proactive company scope (LOOA-629)", () => { + // A proactive plugin (e.g. the chat gateway) makes company-scoped worker→host + // calls from its own timers/loops — outside any host-issued invocation, so + // those calls carry no paperclipInvocationId (the fixture's "omit" mode). The + // host authorizes a bounded set of companies for such proactive work; calls + // referencing an authorized company resolve to that scope, all others stay + // denied. Each case drives a real worker so the nested call flows through the + // worker manager's context resolution, not just the SDK gate in isolation. + function makeHandle(overrides?: { + companiesGet?: ReturnType; + stateGet?: ReturnType; + }) { + const companiesGet = overrides?.companiesGet ?? vi.fn(async () => ({ id: "company-1", name: "Co" })); + const stateGet = overrides?.stateGet ?? vi.fn(async () => ({ value: "ok" })); + const hostHandlers = createHostClientHandlers({ + pluginId: "test.plugin", + capabilities: ["companies.read", "plugin.state.read"], + services: { + companies: { get: companiesGet }, + state: { get: stateGet }, + } as unknown as HostServices, + }); + const handle = createPluginWorkerHandle("test.plugin", { + entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT, + manifest: TEST_MANIFEST, + config: {}, + instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" }, + apiVersion: 1, + hostHandlers, + }); + return { handle, companiesGet, stateGet }; + } + + it("denies a proactive company-scoped call when no company is authorized", async () => { + const { handle, companiesGet } = makeHandle(); + try { + await handle.start(); + await expect(handle.call("getData", { + params: { mode: "omit", hostMethod: "companies.get", requestedCompanyId: "company-1" }, + } as unknown as HostToWorkerMethods["getData"][0])).rejects.toMatchObject({ + code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED, + message: expect.stringContaining("company context is required"), + }); + expect(companiesGet).not.toHaveBeenCalled(); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("admits a proactive company-scoped call for an authorized company", async () => { + const { handle, companiesGet } = makeHandle(); + try { + await handle.start(); + handle.setProactiveCompanyScopes(["company-1"]); + const result = await handle.call("getData", { + params: { mode: "omit", hostMethod: "companies.get", requestedCompanyId: "company-1" }, + } as unknown as HostToWorkerMethods["getData"][0]); + expect(result).toMatchObject({ id: "company-1" }); + expect(companiesGet).toHaveBeenCalledTimes(1); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("admits a proactive state.get (scopeKind company) for an authorized company", async () => { + const { handle, stateGet } = makeHandle(); + try { + await handle.start(); + handle.setProactiveCompanyScopes(["company-1"]); + const result = await handle.call("getData", { + params: { mode: "omit", hostMethod: "state.get", requestedCompanyId: "company-1" }, + } as unknown as HostToWorkerMethods["getData"][0]); + expect(result).toMatchObject({ value: "ok" }); + expect(stateGet).toHaveBeenCalledTimes(1); + expect(stateGet.mock.calls[0]?.[0]).toMatchObject({ scopeKind: "company", scopeId: "company-1" }); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("still denies proactive calls for a company outside the authorized set", async () => { + const { handle, companiesGet } = makeHandle(); + try { + await handle.start(); + handle.setProactiveCompanyScopes(["company-1"]); + await expect(handle.call("getData", { + params: { mode: "omit", hostMethod: "companies.get", requestedCompanyId: "company-2" }, + } as unknown as HostToWorkerMethods["getData"][0])).rejects.toMatchObject({ + code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED, + message: expect.stringContaining("company context is required"), + }); + expect(companiesGet).not.toHaveBeenCalled(); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("revokes proactive access when the authorized set is cleared", async () => { + const { handle, companiesGet } = makeHandle(); + try { + await handle.start(); + handle.setProactiveCompanyScopes(["company-1"]); + await handle.call("getData", { + params: { mode: "omit", hostMethod: "companies.get", requestedCompanyId: "company-1" }, + } as unknown as HostToWorkerMethods["getData"][0]); + expect(companiesGet).toHaveBeenCalledTimes(1); + + handle.setProactiveCompanyScopes([]); + await expect(handle.call("getData", { + params: { mode: "omit", hostMethod: "companies.get", requestedCompanyId: "company-1" }, + } as unknown as HostToWorkerMethods["getData"][0])).rejects.toMatchObject({ + code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED, + }); + expect(companiesGet).toHaveBeenCalledTimes(1); + } finally { + await handle.stop().catch(() => undefined); + } + }); +}); diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index 8db0353236..54681c5818 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -2351,6 +2351,20 @@ export function pluginRoutes( // If it doesn't (METHOD_NOT_IMPLEMENTED), restart the worker so it picks // up the new config on re-initialize. If no worker is running, skip. if (bridgeDeps?.workerManager.isRunning(plugin.id)) { + // Refresh the worker's authorized proactive company scopes so the + // just-configured company can be acted on from proactive loops (e.g. + // the chat gateway's notifier drain) without requiring a restart + // (LOOA-629). The set is exactly the plugin's configured companies. + try { + const configRows = await registry.listConfigs(plugin.id); + bridgeDeps.workerManager.setProactiveCompanyScopes( + plugin.id, + configRows.map((row) => row.companyId), + ); + } catch { + // Non-fatal: the set is rebuilt from the DB on the next worker start. + } + try { await bridgeDeps.workerManager.call( plugin.id, diff --git a/server/src/services/plugin-loader.ts b/server/src/services/plugin-loader.ts index 21fc986607..17ad598aef 100644 --- a/server/src/services/plugin-loader.ts +++ b/server/src/services/plugin-loader.ts @@ -2189,6 +2189,19 @@ export function pluginLoader( // for well-behaved plugins, so replaying an unchanged config is safe. try { const configRows = await registry.listConfigs(pluginId); + + // Authorize the worker to act on each configured company from its + // proactive loops (LOOA-629). A proactive plugin (e.g. the chat + // gateway's notifier drain) makes company-scoped worker→host calls + // outside any host-issued invocation; without this the governed-access + // gate rejects them with "company context is required". The authorized + // set is exactly the plugin's configured companies — proactive access + // never reaches an unconfigured company. + workerManager.setProactiveCompanyScopes( + pluginId, + configRows.map((row) => row.companyId), + ); + for (const row of configRows) { try { await workerManager.call(pluginId, "configChanged", { diff --git a/server/src/services/plugin-worker-manager.ts b/server/src/services/plugin-worker-manager.ts index 71cca23a0a..ced8262be1 100644 --- a/server/src/services/plugin-worker-manager.ts +++ b/server/src/services/plugin-worker-manager.ts @@ -268,6 +268,13 @@ export interface PluginWorkerHandle { */ notify(method: string, params: unknown): void; + /** + * Authorize the set of companies this worker may act on from proactive + * (non-invocation) context. Replaces any previously-authorized set. See the + * proactive-company-scope note in `createPluginWorkerHandle` for rationale. + */ + setProactiveCompanyScopes(companyIds: readonly string[]): void; + /** Subscribe to worker events. */ on( event: K, @@ -336,6 +343,12 @@ export interface PluginWorkerManager { */ isRunning(pluginId: string): boolean; + /** + * Authorize the companies a plugin's worker may act on from proactive + * (non-invocation) context. No-op if the worker is not registered. + */ + setProactiveCompanyScopes(pluginId: string, companyIds: readonly string[]): void; + /** * Stop all managed workers. Called during server shutdown. */ @@ -393,6 +406,22 @@ export function createPluginWorkerHandle( let nextRequestId = 1; const activeInvocations = new Map(); + // ------------------------------------------------------------------ + // Proactive company scopes (LOOA-629) + // ------------------------------------------------------------------ + // A proactive plugin (e.g. the chat gateway) does company-scoped work from + // its own timers/loops — not inside a host-issued top-level invocation + // (onEvent/performAction/executeTool/configChanged). Those worker→host calls + // carry no `paperclipInvocationId`, so the governed-access gate + // (host-client-factory.ts) rejects any company-scoped request with + // "company context is required" (regression class from #9557). The host + // authorizes a bounded set of companies — the plugin's configured companies, + // set by the loader after startup config delivery — for such proactive work. + // A no-invocation call that references one of these companies resolves to + // that company's scope; a call referencing any other company stays denied, + // and in-invocation calls keep their strict single-company match. + const proactiveCompanyScopes = new Set(); + // Optional methods reported by the worker during initialization let supportedMethods: string[] = []; @@ -554,11 +583,43 @@ export function createPluginWorkerHandle( activeInvocations.delete(invocation.id); } + /** + * Extract the company a worker→host call references, mirroring the SDK + * governed-access gate's own derivation (host-client-factory.ts + * `requestedCompanyScope`): an explicit `companyId`, or a company-scoped + * state key (`scopeKind: "company"` + `scopeId`). Returns null when the call + * references no specific company (e.g. `companies.list`, instance-scoped + * state), so proactive resolution only ever grants a single, explicit + * company — never a wildcard. + */ + function referencedCompanyId(params: unknown): string | null { + if (!isRecord(params)) return null; + const direct = readNonEmptyString(params.companyId); + if (direct) return direct; + if (params.scopeKind === "company") { + return readNonEmptyString(params.scopeId); + } + return null; + } + function contextForWorkerMessage(message: JsonRpcRequest | JsonRpcNotification): WorkerHostCallContext { const invocationId = readNonEmptyString( (message as { paperclipInvocationId?: unknown }).paperclipInvocationId, ); if (!invocationId) { + // No host-issued invocation is being echoed. This is a genuinely + // proactive worker→host call (timer/loop). If it references a company the + // plugin is authorized to act on proactively, resolve it to that + // company's scope so the governed-access gate admits it. This never + // widens access beyond the plugin's configured companies, and only + // applies when the worker is NOT inside a host-issued invocation (which + // would carry an id and keep its strict single-company match below). + const proactiveCompanyId = referencedCompanyId( + (message as { params?: unknown }).params, + ); + if (proactiveCompanyId && proactiveCompanyScopes.has(proactiveCompanyId)) { + return { invocationScope: { companyId: proactiveCompanyId } }; + } const hasActiveInvocation = activeInvocations.size > 0 || Array.from(pendingRequests.values()).some((pending) => pending.invocationId); return hasActiveInvocation ? { invalidInvocationScope: true } : {}; @@ -1285,6 +1346,14 @@ export function createPluginWorkerHandle( emitter.off(event, listener); }, + setProactiveCompanyScopes(companyIds: readonly string[]): void { + proactiveCompanyScopes.clear(); + for (const id of companyIds) { + const trimmed = readNonEmptyString(id); + if (trimmed) proactiveCompanyScopes.add(trimmed); + } + }, + diagnostics(): WorkerDiagnostics { return { pluginId, @@ -1439,6 +1508,10 @@ export function createPluginWorkerManager( return handle?.status === "running"; }, + setProactiveCompanyScopes(pluginId: string, companyIds: readonly string[]): void { + workers.get(pluginId)?.setProactiveCompanyScopes(companyIds); + }, + async stopAll(): Promise { log.info({ count: workers.size }, "stopping all plugin workers"); const promises = Array.from(workers.values()).map(async (handle) => {