mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02: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>
131 lines
3.4 KiB
JavaScript
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}`,
|
|
},
|
|
});
|
|
});
|