feat(plugin): scope secret-ref config by company

This commit is contained in:
Paperclip Bot 2026-06-03 06:31:01 +00:00 committed by Alkim Ake Gozen
parent 62863126a3
commit db0ef46900
19 changed files with 587 additions and 102 deletions

View file

@ -0,0 +1,11 @@
ALTER TABLE "plugin_config" ADD COLUMN IF NOT EXISTS "company_id" uuid;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plugin_config_company_id_companies_id_fk') THEN
ALTER TABLE "plugin_config" ADD CONSTRAINT "plugin_config_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DROP INDEX IF EXISTS "plugin_config_plugin_id_idx";--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "plugin_config_plugin_id_idx" ON "plugin_config" USING btree ("plugin_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "plugin_config_company_id_idx" ON "plugin_config" USING btree ("company_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "plugin_config_legacy_plugin_id_uq" ON "plugin_config" USING btree ("plugin_id") WHERE "plugin_config"."company_id" is null;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "plugin_config_company_plugin_uq" ON "plugin_config" USING btree ("plugin_id","company_id") WHERE "plugin_config"."company_id" is not null;

View file

@ -659,6 +659,13 @@
"when": 1780040470886,
"tag": "0093_giant_green_goblin",
"breakpoints": true
},
{
"idx": 94,
"version": "7",
"when": 1780444800000,
"tag": "0094_plugin_config_company_scope",
"breakpoints": true
}
]
}
}

View file

@ -1,10 +1,13 @@
import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex, index } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { plugins } from "./plugins.js";
/**
* `plugin_config` table stores operator-provided instance configuration
* for each plugin (one row per plugin, enforced by a unique index on
* `plugin_id`).
* `plugin_config` table stores operator-provided plugin configuration.
*
* New configuration is company-scoped. Legacy rows may still have a null
* `company_id` so existing installs keep working until re-saved.
*
* The `config_json` column holds the values that the operator enters in the
* plugin settings UI. These values are validated at runtime against the
@ -19,12 +22,21 @@ export const pluginConfig = pgTable(
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
companyId: uuid("company_id")
.references(() => companies.id, { onDelete: "cascade" }),
configJson: jsonb("config_json").$type<Record<string, unknown>>().notNull().default({}),
lastError: text("last_error"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pluginIdIdx: uniqueIndex("plugin_config_plugin_id_idx").on(table.pluginId),
pluginIdIdx: index("plugin_config_plugin_id_idx").on(table.pluginId),
companyIdIdx: index("plugin_config_company_id_idx").on(table.companyId),
legacyPluginIdUq: uniqueIndex("plugin_config_legacy_plugin_id_uq")
.on(table.pluginId)
.where(sql`${table.companyId} is null`),
companyPluginUq: uniqueIndex("plugin_config_company_plugin_uq")
.on(table.pluginId, table.companyId)
.where(sql`${table.companyId} is not null`),
}),
);

View file

@ -100,7 +100,7 @@ export class InvocationScopeDeniedError extends Error {
export interface HostServices {
/** Provides `config.get`. */
config: {
get(): Promise<Record<string, unknown>>;
get(params: WorkerToHostMethods["config.get"][0]): Promise<Record<string, unknown>>;
};
/** Provides trusted company-scoped local folder helpers. */
@ -627,8 +627,8 @@ export function createHostClientHandlers(
return {
// Config
"config.get": gated("config.get", async () => {
return services.config.get();
"config.get": gated("config.get", async (params) => {
return services.config.get(params);
}),
"localFolders.declarations": gated("localFolders.declarations", async (params) => {

View file

@ -672,7 +672,7 @@ export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[]
*/
export interface WorkerToHostMethods {
// Config
"config.get": [params: Record<string, never>, result: Record<string, unknown>];
"config.get": [params: { companyId?: string | null }, result: Record<string, unknown>];
// Trusted local folders
"localFolders.declarations": [
@ -809,7 +809,7 @@ export interface WorkerToHostMethods {
// Secrets
"secrets.resolve": [
params: { secretRef: string },
params: { secretRef: string; companyId?: string | null },
result: string,
];

View file

@ -425,7 +425,7 @@ export interface PluginConfigClient {
* Values are validated against the plugin's `instanceConfigSchema` by the
* host before being passed to the worker.
*/
get(): Promise<Record<string, unknown>>;
get(params?: { companyId?: string | null }): Promise<Record<string, unknown>>;
}
export interface PluginLocalFolderProblem {
@ -656,7 +656,7 @@ export interface PluginSecretsClient {
* @param secretRef - The secret reference string from plugin config
* @returns The resolved secret value
*/
resolve(secretRef: string): Promise<string>;
resolve(secretRef: string, companyId?: string | null): Promise<string>;
}
/**

View file

@ -164,6 +164,10 @@ export interface WorkerRpcHost {
stop(): void;
}
interface RuntimeCompanyContext {
companyId?: string | null;
}
// ---------------------------------------------------------------------------
// Internal: event registration
// ---------------------------------------------------------------------------
@ -285,6 +289,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
let currentConfig: Record<string, unknown> = {};
let databaseNamespace: string | null = null;
const invocationContextStorage = new AsyncLocalStorage<PluginInvocationContext>();
const runtimeCompanyContext = new AsyncLocalStorage<RuntimeCompanyContext>();
// Plugin handler registrations (populated during setup())
const eventHandlers: EventRegistration[] = [];
@ -413,8 +418,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
},
config: {
async get() {
return callHost("config.get", {} as Record<string, never>);
async get(params) {
const companyId =
params?.companyId ?? runtimeCompanyContext.getStore()?.companyId ?? null;
return callHost("config.get", companyId ? { companyId } : {});
},
},
@ -564,8 +571,9 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
},
secrets: {
async resolve(secretRef: string): Promise<string> {
return callHost("secrets.resolve", { secretRef });
async resolve(secretRef: string, companyId?: string | null): Promise<string> {
const scopedCompanyId = companyId ?? runtimeCompanyContext.getStore()?.companyId ?? null;
return callHost("secrets.resolve", { secretRef, companyId: scopedCompanyId });
},
},
@ -1467,7 +1475,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
if (registration.filter && !allowsEvent(registration.filter, event)) continue;
try {
await registration.fn(event);
await runtimeCompanyContext.run(
{ companyId: event.companyId },
() => registration.fn(event),
);
} catch (err) {
// Log error but continue processing other handlers so one failing
// handler doesn't prevent the rest from running.
@ -1507,7 +1518,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
{ code: PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED },
);
}
return plugin.definition.onApiRequest(params);
return runtimeCompanyContext.run(
{ companyId: params.companyId },
() => plugin.definition.onApiRequest!(params),
);
}
async function handleGetData(params: GetDataParams): Promise<unknown> {
@ -1515,11 +1529,14 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
if (!handler) {
throw new Error(`No data handler registered for key "${params.key}"`);
}
return handler({
...params.params,
...(params.companyId === undefined ? {} : { companyId: params.companyId }),
...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }),
});
const handlerParams =
params.renderEnvironment === undefined
? params.params
: { ...params.params, renderEnvironment: params.renderEnvironment };
return runtimeCompanyContext.run(
{ companyId: params.companyId ?? null },
() => handler(handlerParams),
);
}
function stringOrNull(value: unknown): string | null {
@ -1552,13 +1569,14 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
if (!handler) {
throw new Error(`No action handler registered for key "${params.key}"`);
}
return handler(
{
...params.params,
...(params.companyId === undefined ? {} : { companyId: params.companyId }),
...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }),
},
actionContextFromParams(params),
const handlerParams =
params.renderEnvironment === undefined
? params.params
: { ...params.params, renderEnvironment: params.renderEnvironment };
const actionContext = actionContextFromParams(params);
return runtimeCompanyContext.run(
{ companyId: params.companyId ?? actionContext.companyId },
() => handler(handlerParams ?? {}, actionContext),
);
}
@ -1567,7 +1585,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
if (!entry) {
throw new Error(`No tool handler registered for "${params.toolName}"`);
}
return entry.fn(params.parameters, params.runContext);
return runtimeCompanyContext.run(
{ companyId: params.runContext.companyId },
() => entry.fn(params.parameters, params.runContext),
);
}
function methodNotImplemented(method: string): Error & { code: number } {
@ -1590,49 +1611,70 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
if (!plugin.definition.onEnvironmentProbe) {
throw methodNotImplemented("environmentProbe");
}
return plugin.definition.onEnvironmentProbe(params);
return runtimeCompanyContext.run(
{ companyId: params.companyId },
() => plugin.definition.onEnvironmentProbe!(params),
);
}
async function handleEnvironmentAcquireLease(params: PluginEnvironmentAcquireLeaseParams) {
if (!plugin.definition.onEnvironmentAcquireLease) {
throw methodNotImplemented("environmentAcquireLease");
}
return plugin.definition.onEnvironmentAcquireLease(params);
return runtimeCompanyContext.run(
{ companyId: params.companyId },
() => plugin.definition.onEnvironmentAcquireLease!(params),
);
}
async function handleEnvironmentResumeLease(params: PluginEnvironmentResumeLeaseParams) {
if (!plugin.definition.onEnvironmentResumeLease) {
throw methodNotImplemented("environmentResumeLease");
}
return plugin.definition.onEnvironmentResumeLease(params);
return runtimeCompanyContext.run(
{ companyId: params.companyId },
() => plugin.definition.onEnvironmentResumeLease!(params),
);
}
async function handleEnvironmentReleaseLease(params: PluginEnvironmentReleaseLeaseParams) {
if (!plugin.definition.onEnvironmentReleaseLease) {
throw methodNotImplemented("environmentReleaseLease");
}
return plugin.definition.onEnvironmentReleaseLease(params);
return runtimeCompanyContext.run(
{ companyId: params.companyId },
() => plugin.definition.onEnvironmentReleaseLease!(params),
);
}
async function handleEnvironmentDestroyLease(params: PluginEnvironmentDestroyLeaseParams) {
if (!plugin.definition.onEnvironmentDestroyLease) {
throw methodNotImplemented("environmentDestroyLease");
}
return plugin.definition.onEnvironmentDestroyLease(params);
return runtimeCompanyContext.run(
{ companyId: params.companyId },
() => plugin.definition.onEnvironmentDestroyLease!(params),
);
}
async function handleEnvironmentRealizeWorkspace(params: PluginEnvironmentRealizeWorkspaceParams) {
if (!plugin.definition.onEnvironmentRealizeWorkspace) {
throw methodNotImplemented("environmentRealizeWorkspace");
}
return plugin.definition.onEnvironmentRealizeWorkspace(params);
return runtimeCompanyContext.run(
{ companyId: params.companyId },
() => plugin.definition.onEnvironmentRealizeWorkspace!(params),
);
}
async function handleEnvironmentExecute(params: PluginEnvironmentExecuteParams) {
if (!plugin.definition.onEnvironmentExecute) {
throw methodNotImplemented("environmentExecute");
}
return plugin.definition.onEnvironmentExecute(params);
return runtimeCompanyContext.run(
{ companyId: params.companyId },
() => plugin.definition.onEnvironmentExecute!(params),
);
}
// -----------------------------------------------------------------------

View file

@ -296,3 +296,124 @@ describe("worker invocation scope propagation", () => {
}
});
});
describe("startWorkerRpcHost runtime company context", () => {
function collectJsonLines(stream: PassThrough) {
const queue: unknown[] = [];
const waiters: Array<(value: unknown) => void> = [];
let buffer = "";
stream.on("data", (chunk) => {
buffer += chunk.toString("utf8");
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (line) {
const message = JSON.parse(line);
const waiter = waiters.shift();
if (waiter) waiter(message);
else queue.push(message);
}
newlineIndex = buffer.indexOf("\n");
}
});
return async function nextMessage(): Promise<any> {
const queued = queue.shift();
if (queued) return queued;
return new Promise((resolve) => waiters.push(resolve));
};
}
function writeMessage(stream: PassThrough, message: unknown): void {
stream.write(`${JSON.stringify(message)}\n`);
}
it("passes executeTool company context into config and secret host calls", async () => {
const stdin = new PassThrough();
const stdout = new PassThrough();
const nextMessage = collectJsonLines(stdout);
const plugin = definePlugin({
async setup(ctx) {
ctx.tools.register(
"check-context",
{
displayName: "Check Context",
description: "Checks runtime context propagation",
parametersSchema: { type: "object", properties: {} },
},
async () => {
const config = await ctx.config.get();
const token = await ctx.secrets.resolve("77777777-7777-4777-8777-777777777777");
return { content: `${config.mode}:${token}` };
},
);
},
});
const host = startWorkerRpcHost({ plugin, stdin, stdout });
writeMessage(stdin, {
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
manifest: { id: "test-plugin", name: "test-plugin", version: "1.0.0" },
config: {},
instanceInfo: { instanceId: "inst-1", hostVersion: "0.0.0-test" },
apiVersion: 1,
},
});
await expect(nextMessage()).resolves.toMatchObject({ id: 1, result: { ok: true } });
writeMessage(stdin, {
jsonrpc: "2.0",
id: 2,
method: "executeTool",
params: {
toolName: "check-context",
parameters: {},
runContext: {
agentId: "agent-1",
runId: "run-1",
companyId: "company-1",
projectId: "project-1",
},
},
});
const configRequest = await nextMessage();
expect(configRequest).toMatchObject({
method: "config.get",
params: { companyId: "company-1" },
});
writeMessage(stdin, {
jsonrpc: "2.0",
id: configRequest.id,
result: { mode: "company-config" },
});
const secretRequest = await nextMessage();
expect(secretRequest).toMatchObject({
method: "secrets.resolve",
params: {
secretRef: "77777777-7777-4777-8777-777777777777",
companyId: "company-1",
},
});
writeMessage(stdin, {
jsonrpc: "2.0",
id: secretRequest.id,
result: "company-secret",
});
await expect(nextMessage()).resolves.toMatchObject({
id: 2,
result: { content: "company-config:company-secret" },
});
host.stop();
});
});

View file

@ -678,15 +678,17 @@ export interface PluginStateRecord {
// ---------------------------------------------------------------------------
/**
* Domain type for a plugin's instance configuration as persisted in the
* Domain type for a plugin's configuration as persisted in the
* `plugin_config` table.
* See PLUGIN_SPEC.md §21.3 for the schema definition.
*/
export interface PluginConfig {
/** UUID primary key. */
id: string;
/** FK to `plugins.id`. Unique — each plugin has at most one config row. */
/** FK to `plugins.id`. */
pluginId: string;
/** Company scope for this config row. Null only for legacy/global fallback rows. */
companyId: string | null;
/** Operator-provided configuration values (validated against `instanceConfigSchema`). */
configJson: Record<string, unknown>;
/** Most recent config validation error, if any. */