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:
parent
a17bee98f2
commit
3093c5e694
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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 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<void> {
|
||||
log.info({ count: workers.size }, "stopping all plugin workers");
|
||||
const promises = Array.from(workers.values()).map(async (handle) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue