fix(plugin-worker): resolve a company scope for proactive worker→host calls (#10103)

Host authorizes the plugin's configured companies as the worker's proactive scopes, set by the loader right after the #10092 config-delivery step and refreshed on operator config-save. At the single worker→host chokepoint, a no-invocation call (notifier drain, decision reconcile, mirror drain, digest, aging, liveness beat) that references a configured company resolves to that company's scope, so the #9557 governed-access gate admits it. One change covers the full proactive surface (state.*, issues.*, approvals.*, config.get, secrets.resolve, etc.).

Safety: never widens beyond configured companies (any other company stays denied); in-invocation calls keep #9557's strict single-company match untouched.

Fixes the Slack gateway DM round-trip for LOOA-629. Security review PASS (LOOA-693); non-blocking LOW follow-up tracked in LOOA-694.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Michael Nguyen 2026-07-23 11:06:02 -07:00 committed by GitHub
parent a17bee98f2
commit 3093c5e694
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 231 additions and 0 deletions

View File

@ -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,
};

View File

@ -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<typeof vi.fn>;
stateGet?: ReturnType<typeof vi.fn>;
}) {
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);
}
});
});

View File

@ -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,

View File

@ -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", {

View File

@ -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<K extends WorkerHandleEventName>(
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<string, ActiveInvocation>();
// ------------------------------------------------------------------
// 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<string>();
// 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 workerhost 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<void> {
log.info({ count: workers.size }, "stopping all plugin workers");
const promises = Array.from(workers.values()).map(async (handle) => {