mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
- [x] I searched the GitHub PR list for similar PRs (dedup search). No open PR touches the proactive `events.subscribe` ordering path; #10103 (merged) is the predecessor whose ordering bug this fixes. ## Thinking Path The gateway worker's outbound push path is permanently dead (`eventSubscriptions: 0`, `notifier.received: 0`, `decisions.delivered: 0`). The plugin loader authorizes the worker's **proactive company scopes only AFTER `startWorker` resolves**, but a proactive plugin issues its one-shot `events.subscribe` calls from `setup()` — which runs *while `startWorker` is still awaiting the worker's initialize response*. So at subscribe time `proactiveCompanyScopes` is still empty → `contextForWorkerMessage` resolves no scope → the governed-access gate rejects every subscribe with `company context is required`. The gateway subscribes once and never retries, so `eventSubscriptions` stays 0 for the worker's life. This is an **ordering bug in the #10103 fix**, not a new method — same #9557 governed-access class as `config.get` (#10092) and `state.get` (#10103). Confirmed live at the 18:21:21Z worker respawn on `3093c5e` (host log), and again at the 19:01:04Z restart (still `events.subscribe: company context is required`, `eventSubscriptions:0`). ## What Changed 1. **Loader ordering** (`plugin-loader.ts`): load `registry.listConfigs(pluginId)` in a new step 4b **before** `startWorker`, and thread the configured company set into `WorkerStartOptions.proactiveCompanyScopes` so the worker handle is authorized *before the child process issues any host call*. The same rows are reused for startup config delivery (step 5b) — no second `listConfigs` round-trip. The runtime config-change path (`routes/plugins.ts`) still refreshes scopes via `setProactiveCompanyScopes` (unchanged). 2. **Handle seed** (`plugin-worker-manager.ts`): `createPluginWorkerHandle` seeds its `proactiveCompanyScopes` set from options at creation, before spawn. 3. **Resolver/gate parity** (`plugin-worker-manager.ts`): `referencedCompanyId(method, params)` now mirrors the SDK gate `requestedCompanyScope` exactly in the functional direction — adds `events.subscribe → params.filter.companyId` (how `ctx.events.on(name, { companyId }, fn)` issues its subscribe), and declines the gate's wildcard cases (`companies.list`, `scopeKind:"company"` without `scopeId`) so proactive access only ever grants a **single explicit configured company, never "all"**. Answers LOOA-693 AC#4 (host/gate extraction parity) in the functional direction. ## Tests New `plugin-worker-manager.test.ts` cases (drive a real worker): - a `setup()`-time `events.subscribe({ filter: { companyId } })` for an options-seeded company is **admitted** (fails on prior code — no options seed, no filter parity); - an unconfigured company stays **denied**; - an unseeded worker stays **denied**. Full `plugin-worker-manager.test.ts` suite: **21 passed**. Server `tsc --noEmit`: clean. All PR CI green (typecheck, server/workspace suites, e2e, build, security scans). ## Risks - **Scope-widening risk (primary).** The change grants proactive host access keyed off configured company rows. Mitigated by: the authorized set is exactly `registry.listConfigs(pluginId).map(companyId)`; wildcard cases (`companies.list`, company-scoped key without `scopeId`) resolve to `null`, never `{ kind: all }`; empty/whitespace ids dropped; an empty config set grants zero proactive access. This is the surface SecurityEngineer must sign off (see Security gate). - **In-invocation path unchanged.** Calls carrying a host-issued `paperclipInvocationId` keep the existing strict single-company match; the proactive branch only applies when there is no invocation id — so no regression to the enforced request path. - **Blast radius.** Loader step 4b is best-effort: a `listConfigs` failure logs and proceeds with an empty seed (fails closed — no push, not a crash), matching today's behavior. ## Model Used Claude Opus 4.8 (`claude-opus-4-8`) via Claude Code (agent: CTO). ## Security gate Touches the company-scope resolution path (same surface as #10103). Routed through **SecurityEngineer review before merge** (tracked on LOOA-696) — must not widen beyond configured companies; in-invocation strict single-company match untouched; wildcard cases deliberately declined in the proactive direction. ## Verification once live - Host log clean of `events.subscribe: company context is required` at worker start - loader logs `eventSubscriptions: N>0` - beat `notifier.received` / `decisions.delivered` move on real issue/approval activity Parent: LOOA-629 (outbound push half of "gateway active"). LOOA-695. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
731 lines
25 KiB
TypeScript
731 lines
25 KiB
TypeScript
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
|
import {
|
|
createHostClientHandlers,
|
|
JsonRpcCallError,
|
|
PLUGIN_RPC_ERROR_CODES,
|
|
type HostServices,
|
|
type HostToWorkerMethods,
|
|
} from "@paperclipai/plugin-sdk";
|
|
import {
|
|
appendStderrExcerpt,
|
|
createPluginWorkerHandle,
|
|
formatWorkerFailureMessage,
|
|
} from "../services/plugin-worker-manager.js";
|
|
|
|
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
|
|
const DELAYED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-delayed.cjs");
|
|
const INVOCATION_SCOPE_WORKER_ENTRYPOINT = path.join(
|
|
FIXTURES_DIR,
|
|
"plugin-worker-invocation-scope.cjs",
|
|
);
|
|
const TERMINATED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-terminated.cjs");
|
|
|
|
const TEST_MANIFEST: PaperclipPluginManifestV1 = {
|
|
id: "test.plugin",
|
|
apiVersion: 1,
|
|
version: "1.0.0",
|
|
displayName: "Test plugin",
|
|
description: "Test plugin",
|
|
author: "Paperclip",
|
|
categories: ["automation"],
|
|
capabilities: [],
|
|
entrypoints: { worker: "dist/worker.js" },
|
|
};
|
|
|
|
describe("plugin-worker-manager stderr failure context", () => {
|
|
it("appends worker stderr context to failure messages", () => {
|
|
expect(
|
|
formatWorkerFailureMessage(
|
|
"Worker process exited (code=1, signal=null)",
|
|
"TypeError: Unknown file extension \".ts\"",
|
|
),
|
|
).toBe(
|
|
"Worker process exited (code=1, signal=null)\n\nWorker stderr:\nTypeError: Unknown file extension \".ts\"",
|
|
);
|
|
});
|
|
|
|
it("does not duplicate stderr that is already present", () => {
|
|
const message = [
|
|
"Worker process exited (code=1, signal=null)",
|
|
"",
|
|
"Worker stderr:",
|
|
"TypeError: Unknown file extension \".ts\"",
|
|
].join("\n");
|
|
|
|
expect(
|
|
formatWorkerFailureMessage(message, "TypeError: Unknown file extension \".ts\""),
|
|
).toBe(message);
|
|
});
|
|
|
|
it("keeps only the latest stderr excerpt", () => {
|
|
let excerpt = "";
|
|
excerpt = appendStderrExcerpt(excerpt, "first line");
|
|
excerpt = appendStderrExcerpt(excerpt, "second line");
|
|
|
|
expect(excerpt).toContain("first line");
|
|
expect(excerpt).toContain("second line");
|
|
|
|
excerpt = appendStderrExcerpt(excerpt, "x".repeat(9_000));
|
|
|
|
expect(excerpt).not.toContain("first line");
|
|
expect(excerpt).not.toContain("second line");
|
|
expect(excerpt.length).toBeLessThanOrEqual(8_000);
|
|
});
|
|
|
|
it("times out environmentExecute calls using the handle default when no override is provided", async () => {
|
|
const handle = createPluginWorkerHandle("test.plugin", {
|
|
entrypointPath: DELAYED_WORKER_ENTRYPOINT,
|
|
manifest: TEST_MANIFEST,
|
|
config: {},
|
|
instanceInfo: {
|
|
instanceId: "instance-1",
|
|
hostVersion: "1.0.0",
|
|
},
|
|
apiVersion: 1,
|
|
hostHandlers: {},
|
|
rpcTimeoutMs: 10,
|
|
});
|
|
|
|
try {
|
|
await handle.start();
|
|
|
|
await expect(handle.call("environmentExecute", {
|
|
driverKey: "e2b",
|
|
companyId: "company-1",
|
|
environmentId: "environment-1",
|
|
config: {},
|
|
lease: { providerLeaseId: "lease-1" },
|
|
command: "echo",
|
|
delayMs: 50,
|
|
} as HostToWorkerMethods["environmentExecute"][0])).rejects.toMatchObject({
|
|
message: expect.stringContaining("timed out after 10ms"),
|
|
});
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("honors per-call timeout overrides for environmentExecute", async () => {
|
|
const handle = createPluginWorkerHandle("test.plugin", {
|
|
entrypointPath: DELAYED_WORKER_ENTRYPOINT,
|
|
manifest: TEST_MANIFEST,
|
|
config: {},
|
|
instanceInfo: {
|
|
instanceId: "instance-1",
|
|
hostVersion: "1.0.0",
|
|
},
|
|
apiVersion: 1,
|
|
hostHandlers: {},
|
|
rpcTimeoutMs: 10,
|
|
});
|
|
|
|
try {
|
|
await handle.start();
|
|
|
|
await expect(handle.call("environmentExecute", {
|
|
driverKey: "e2b",
|
|
companyId: "company-1",
|
|
environmentId: "environment-1",
|
|
config: {},
|
|
lease: { providerLeaseId: "lease-1" },
|
|
command: "echo",
|
|
delayMs: 50,
|
|
} as HostToWorkerMethods["environmentExecute"][0], 100)).resolves.toMatchObject({
|
|
exitCode: 0,
|
|
stdout: "ok\n",
|
|
});
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("does not emit an unhandled rejection when a plugin responds with terminated before callers attach handlers", async () => {
|
|
const unhandledRejection = vi.fn();
|
|
process.on("unhandledRejection", unhandledRejection);
|
|
|
|
const handle = createPluginWorkerHandle("test.plugin", {
|
|
entrypointPath: TERMINATED_WORKER_ENTRYPOINT,
|
|
manifest: TEST_MANIFEST,
|
|
config: {},
|
|
instanceInfo: {
|
|
instanceId: "instance-1",
|
|
hostVersion: "1.0.0",
|
|
},
|
|
apiVersion: 1,
|
|
hostHandlers: {},
|
|
});
|
|
|
|
try {
|
|
await handle.start();
|
|
|
|
const pendingCall = handle.call(
|
|
"environmentExecute" as keyof HostToWorkerMethods,
|
|
{
|
|
driverKey: "e2b",
|
|
companyId: "company-1",
|
|
environmentId: "environment-1",
|
|
config: {},
|
|
lease: { providerLeaseId: "lease-1" },
|
|
command: "echo",
|
|
} as HostToWorkerMethods[keyof HostToWorkerMethods][0],
|
|
);
|
|
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
await expect(pendingCall).rejects.toBeInstanceOf(JsonRpcCallError);
|
|
await expect(pendingCall).rejects.toMatchObject({
|
|
message: expect.stringContaining("terminated"),
|
|
});
|
|
expect(unhandledRejection).not.toHaveBeenCalled();
|
|
} finally {
|
|
process.off("unhandledRejection", unhandledRejection);
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("passes performAction invocation scope to nested worker host calls", async () => {
|
|
const companiesGet = vi.fn(async (
|
|
params: { companyId: string },
|
|
context?: { invocationScope?: { companyId?: string | null } | null },
|
|
) => ({
|
|
id: params.companyId,
|
|
scopedCompanyId: context?.invocationScope?.companyId ?? null,
|
|
}));
|
|
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: {
|
|
"companies.get": companiesGet as never,
|
|
},
|
|
});
|
|
|
|
try {
|
|
await handle.start();
|
|
|
|
await expect(handle.call("performAction", {
|
|
key: "probe",
|
|
params: {
|
|
mode: "echo",
|
|
requestedCompanyId: "company-a",
|
|
},
|
|
actorContext: {
|
|
type: "agent",
|
|
userId: null,
|
|
agentId: "agent-1",
|
|
runId: "run-1",
|
|
companyId: "company-a",
|
|
},
|
|
renderEnvironment: null,
|
|
})).resolves.toEqual({
|
|
id: "company-a",
|
|
scopedCompanyId: "company-a",
|
|
});
|
|
expect(companiesGet).toHaveBeenCalledWith(
|
|
{ companyId: "company-a" },
|
|
{ invocationScope: { companyId: "company-a" } },
|
|
);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("passes echoed invocation scope to worker-to-host handlers", async () => {
|
|
const companiesGet = vi.fn(async () => ({ id: "company-1" }));
|
|
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: {
|
|
"companies.get": companiesGet,
|
|
},
|
|
});
|
|
|
|
try {
|
|
await handle.start();
|
|
|
|
await expect(handle.call("getData", {
|
|
key: "probe",
|
|
companyId: "company-1",
|
|
params: {
|
|
mode: "echo",
|
|
requestedCompanyId: "company-1",
|
|
},
|
|
} as HostToWorkerMethods["getData"][0])).resolves.toEqual({ id: "company-1" });
|
|
|
|
expect(companiesGet).toHaveBeenCalledWith(
|
|
{ companyId: "company-1" },
|
|
{ invocationScope: { companyId: "company-1" } },
|
|
);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("rejects performAction nested host calls that omit the invocation id", async () => {
|
|
const handlers = createHostClientHandlers({
|
|
pluginId: "test.plugin",
|
|
capabilities: ["companies.read"],
|
|
services: {
|
|
companies: {
|
|
list: vi.fn(async () => []),
|
|
get: vi.fn(async (params: { companyId: string }) => ({ id: params.companyId })),
|
|
},
|
|
} 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: handlers,
|
|
});
|
|
|
|
try {
|
|
await handle.start();
|
|
|
|
await expect(handle.call("performAction", {
|
|
key: "probe",
|
|
params: {
|
|
requestedCompanyId: "company-b",
|
|
},
|
|
actorContext: {
|
|
type: "agent",
|
|
userId: null,
|
|
agentId: "agent-1",
|
|
runId: "run-1",
|
|
companyId: "company-a",
|
|
},
|
|
renderEnvironment: null,
|
|
})).rejects.toMatchObject({
|
|
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
|
message: expect.stringContaining("unknown invocation scope"),
|
|
});
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("rejects nested worker host calls that forge an unknown invocation id", async () => {
|
|
const companiesGet = vi.fn(async (params: { companyId: string }) => ({ id: params.companyId }));
|
|
const handlers = createHostClientHandlers({
|
|
pluginId: "test.plugin",
|
|
capabilities: ["companies.read"],
|
|
services: {
|
|
companies: {
|
|
get: companiesGet,
|
|
},
|
|
} 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: handlers,
|
|
});
|
|
|
|
try {
|
|
await handle.start();
|
|
|
|
await expect(handle.call("performAction", {
|
|
key: "probe",
|
|
params: {
|
|
mode: "unknown",
|
|
requestedCompanyId: "company-a",
|
|
},
|
|
actorContext: {
|
|
type: "agent",
|
|
userId: null,
|
|
agentId: "agent-1",
|
|
runId: "run-1",
|
|
companyId: "company-a",
|
|
},
|
|
renderEnvironment: null,
|
|
})).rejects.toMatchObject({
|
|
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
|
message: expect.stringContaining("unknown invocation scope"),
|
|
});
|
|
expect(companiesGet).not.toHaveBeenCalled();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("rejects missing or unknown invocation ids while a company invocation is active", async () => {
|
|
const companiesGet = vi.fn(async () => ({ id: "company-2" }));
|
|
const hostHandlers = createHostClientHandlers({
|
|
pluginId: "test.plugin",
|
|
capabilities: ["companies.read"],
|
|
services: {
|
|
companies: {
|
|
get: companiesGet,
|
|
},
|
|
} 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,
|
|
});
|
|
|
|
try {
|
|
await handle.start();
|
|
|
|
for (const mode of ["omit", "unknown"]) {
|
|
await expect(handle.call("getData", {
|
|
key: "probe",
|
|
companyId: "company-1",
|
|
params: {
|
|
mode,
|
|
requestedCompanyId: "company-2",
|
|
},
|
|
} as HostToWorkerMethods["getData"][0])).rejects.toMatchObject({
|
|
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
|
});
|
|
}
|
|
|
|
expect(companiesGet).not.toHaveBeenCalled();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
});
|
|
|
|
|
|
describe("plugin host company context guards", () => {
|
|
it("rejects config and secret calls without host-issued company context before host services run", async () => {
|
|
const configGet = vi.fn(async () => ({ apiKey: "unreachable" }));
|
|
const secretsResolve = vi.fn(async () => "unreachable");
|
|
const handlers = createHostClientHandlers({
|
|
pluginId: "test.plugin",
|
|
capabilities: ["secrets.read-ref"],
|
|
services: {
|
|
config: { get: configGet },
|
|
secrets: { resolve: secretsResolve },
|
|
} as unknown as HostServices,
|
|
});
|
|
|
|
await expect(handlers["config.get"]({})).rejects.toMatchObject({
|
|
name: "InvocationScopeDeniedError",
|
|
message: expect.stringContaining("company context is required"),
|
|
});
|
|
await expect(handlers["config.get"]({ companyId: "company-1" })).rejects.toMatchObject({
|
|
name: "InvocationScopeDeniedError",
|
|
message: expect.stringContaining("company context is required"),
|
|
});
|
|
await expect(
|
|
handlers["secrets.resolve"]({
|
|
secretRef: { type: "secret_ref", secretId: "11111111-1111-4111-8111-111111111111" },
|
|
}),
|
|
).rejects.toMatchObject({
|
|
name: "InvocationScopeDeniedError",
|
|
message: expect.stringContaining("company context is required"),
|
|
});
|
|
await expect(
|
|
handlers["secrets.resolve"]({
|
|
companyId: "company-1",
|
|
secretRef: { type: "secret_ref", secretId: "11111111-1111-4111-8111-111111111111" },
|
|
}),
|
|
).rejects.toMatchObject({
|
|
name: "InvocationScopeDeniedError",
|
|
message: expect.stringContaining("company context is required"),
|
|
});
|
|
|
|
expect(configGet).not.toHaveBeenCalled();
|
|
expect(secretsResolve).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects cross-company config and secret reads in scoped worker invocations before host services run", async () => {
|
|
const configGet = vi.fn(async () => ({ apiKeyRef: "unreachable" }));
|
|
const secretsResolve = vi.fn(async () => "unreachable");
|
|
const hostHandlers = createHostClientHandlers({
|
|
pluginId: "test.plugin",
|
|
capabilities: ["secrets.read-ref"],
|
|
services: {
|
|
config: { get: configGet },
|
|
secrets: { resolve: secretsResolve },
|
|
} 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,
|
|
});
|
|
|
|
try {
|
|
await handle.start();
|
|
|
|
for (const hostMethod of ["config.get", "secrets.resolve"] as const) {
|
|
await expect(handle.call("performAction", {
|
|
key: "probe",
|
|
params: {
|
|
mode: "echo",
|
|
hostMethod,
|
|
requestedCompanyId: "company-b",
|
|
},
|
|
actorContext: {
|
|
type: "agent",
|
|
userId: null,
|
|
agentId: "agent-1",
|
|
runId: "run-1",
|
|
companyId: "company-a",
|
|
},
|
|
renderEnvironment: null,
|
|
})).rejects.toMatchObject({
|
|
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
|
message: expect.stringContaining('requested company "company-b"'),
|
|
});
|
|
}
|
|
|
|
expect(configGet).not.toHaveBeenCalled();
|
|
expect(secretsResolve).not.toHaveBeenCalled();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
});
|
|
|
|
|
|
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);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("plugin proactive events.subscribe: options-seeded scope + filter parity (LOOA-695)", () => {
|
|
// The chat gateway subscribes to issue.*/approval.* from setup() via
|
|
// ctx.events.on(name, { companyId }, fn), which the SDK turns into a proactive
|
|
// (no-invocation) events.subscribe whose company lives in params.filter.companyId.
|
|
// Two things had to hold for outbound push to work and neither did before this
|
|
// fix:
|
|
// (1) the authorized company set must be present BEFORE the worker's setup()
|
|
// calls land — the loader used to set it only after startWorker resolved,
|
|
// so it was seeded via WorkerStartOptions at handle creation instead;
|
|
// (2) the host's proactive-scope resolver (referencedCompanyId) must derive
|
|
// events.subscribe's company from filter.companyId, mirroring the SDK
|
|
// gate (requestedCompanyScope).
|
|
// Each case drives a real worker so the subscribe flows through the manager's
|
|
// context resolution exactly as it does in production.
|
|
function makeEventsHandle(seededCompanies: readonly string[]) {
|
|
const eventsSubscribe = vi.fn(async () => undefined);
|
|
const hostHandlers = createHostClientHandlers({
|
|
pluginId: "test.plugin",
|
|
capabilities: ["events.subscribe"],
|
|
services: {
|
|
events: { subscribe: eventsSubscribe },
|
|
} 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,
|
|
// Seeded at handle creation — the loader now threads the plugin's
|
|
// configured companies here BEFORE startWorker, never via a post-start
|
|
// setProactiveCompanyScopes call.
|
|
proactiveCompanyScopes: seededCompanies,
|
|
});
|
|
return { handle, eventsSubscribe };
|
|
}
|
|
|
|
it("admits a setup()-time events.subscribe for a company seeded via WorkerStartOptions", async () => {
|
|
const { handle, eventsSubscribe } = makeEventsHandle(["company-1"]);
|
|
try {
|
|
await handle.start();
|
|
// No post-start setProactiveCompanyScopes call: the seed from options is
|
|
// the only authorization, exactly as it is when the worker subscribes
|
|
// during setup() before startWorker resolves.
|
|
await handle.call("getData", {
|
|
params: { mode: "omit", hostMethod: "events.subscribe", requestedCompanyId: "company-1" },
|
|
} as unknown as HostToWorkerMethods["getData"][0]);
|
|
expect(eventsSubscribe).toHaveBeenCalledTimes(1);
|
|
expect(eventsSubscribe.mock.calls[0]?.[0]).toMatchObject({
|
|
filter: { companyId: "company-1" },
|
|
});
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("still denies a proactive events.subscribe for a company outside the seeded set", async () => {
|
|
const { handle, eventsSubscribe } = makeEventsHandle(["company-1"]);
|
|
try {
|
|
await handle.start();
|
|
await expect(handle.call("getData", {
|
|
params: { mode: "omit", hostMethod: "events.subscribe", 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(eventsSubscribe).not.toHaveBeenCalled();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("denies a proactive events.subscribe when no company is seeded", async () => {
|
|
const { handle, eventsSubscribe } = makeEventsHandle([]);
|
|
try {
|
|
await handle.start();
|
|
await expect(handle.call("getData", {
|
|
params: { mode: "omit", hostMethod: "events.subscribe", requestedCompanyId: "company-1" },
|
|
} as unknown as HostToWorkerMethods["getData"][0])).rejects.toMatchObject({
|
|
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
|
});
|
|
expect(eventsSubscribe).not.toHaveBeenCalled();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
});
|