mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
feat(plugin): scope secret-ref config by company
This commit is contained in:
parent
62863126a3
commit
db0ef46900
19 changed files with 587 additions and 102 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue