Files
PaperClipAI/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs
T
Michael NguyenandClaude Opus 4.8 f2f168f6a1 fix(plugins): seed proactive company scopes before worker setup() + events.subscribe resolver parity (LOOA-695) (#10113)
- [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>
2026-07-23 12:32:10 -07:00

131 lines
3.4 KiB
JavaScript

const readline = require("node:readline");
let nextRequestId = 1;
const pendingNested = new Map();
function send(message) {
process.stdout.write(`${JSON.stringify(message)}\n`);
}
function sendNestedHostRequest(originalRequest, invocationId) {
const nestedId = `nested-${nextRequestId++}`;
const params = originalRequest.params?.params ?? {};
const mode = params.mode;
const requestedCompanyId = params.requestedCompanyId;
const hostMethod = params.hostMethod || "companies.get";
const nestedParams = hostMethod === "secrets.resolve"
? {
companyId: requestedCompanyId,
secretRef: {
type: "secret_ref",
secretId: params.secretId || "11111111-1111-4111-8111-111111111111",
},
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",
}
: hostMethod === "events.subscribe"
? {
// The subscribe shape the SDK issues from setup() via
// ctx.events.on(name, { companyId }, fn): the requested company lives in
// filter.companyId, NOT a top-level companyId. The host resolver must
// mirror the SDK gate and read it from there (LOOA-695).
eventPattern: params.eventPattern || "issue.updated",
filter: { companyId: requestedCompanyId },
}
: {
companyId: requestedCompanyId,
};
const nestedRequest = {
jsonrpc: "2.0",
id: nestedId,
method: hostMethod,
params: nestedParams,
};
if (mode === "echo") {
nestedRequest.paperclipInvocationId = invocationId;
} else if (mode === "unknown") {
nestedRequest.paperclipInvocationId = "unknown-invocation";
}
pendingNested.set(nestedId, originalRequest.id);
send(nestedRequest);
}
const rl = readline.createInterface({
input: process.stdin,
crlfDelay: Infinity,
});
rl.on("line", (line) => {
if (!line.trim()) return;
const message = JSON.parse(line);
if (message.id && pendingNested.has(message.id)) {
const originalId = pendingNested.get(message.id);
pendingNested.delete(message.id);
if (message.error) {
send({
jsonrpc: "2.0",
id: originalId,
error: message.error,
});
return;
}
send({
jsonrpc: "2.0",
id: originalId,
result: message.result,
});
return;
}
const method = message && typeof message.method === "string" ? message.method : null;
if (method === "initialize") {
send({
jsonrpc: "2.0",
id: message.id,
result: {
ok: true,
supportedMethods: ["getData", "performAction"],
},
});
return;
}
if (method === "getData" || method === "performAction") {
sendNestedHostRequest(message, message.paperclipInvocation?.id);
return;
}
if (method === "shutdown") {
send({
jsonrpc: "2.0",
id: message.id,
result: {},
});
setImmediate(() => process.exit(0));
return;
}
send({
jsonrpc: "2.0",
id: message.id,
error: {
code: -32601,
message: `Unhandled method: ${method}`,
},
});
});