fix(webhooks): reload route secrets per request (#70727)

* fix(webhooks): reload route secrets per request

* docs(changelog): note webhook secret reload fix
This commit is contained in:
Devin Robison
2026-04-23 15:48:10 -06:00
committed by GitHub
parent e64da8bde0
commit 36c4a372a0
3 changed files with 27 additions and 13 deletions

View File

@@ -158,9 +158,10 @@ describe("createTaskFlowWebhookRequestHandler", () => {
expect(res.statusCode).toBe(401);
expect(res.body).toBe("unauthorized");
expect(target.taskFlow.list()).toEqual([]);
expect(hoisted.resolveConfiguredSecretInputStringMock).not.toHaveBeenCalled();
});
it("caches SecretRef resolution across requests for the same route", async () => {
it("re-resolves SecretRef-backed secrets across requests", async () => {
const runtime = createRuntimeTaskFlow();
const target: TaskFlowWebhookTarget = {
routeId: "cached",
@@ -176,7 +177,10 @@ describe("createTaskFlowWebhookRequestHandler", () => {
sessionKey: "agent:main:webhook-cached",
}),
};
hoisted.resolveConfiguredSecretInputStringMock.mockResolvedValue({ value: "shared-secret" });
hoisted.resolveConfiguredSecretInputStringMock
.mockResolvedValueOnce({ value: "shared-secret" })
.mockResolvedValueOnce({ value: "rotated-secret" })
.mockResolvedValueOnce({ value: "rotated-secret" });
const handler = createHandlerWithTarget(target);
const first = await dispatchJsonRequest({
@@ -195,10 +199,20 @@ describe("createTaskFlowWebhookRequestHandler", () => {
action: "list_flows",
},
});
const third = await dispatchJsonRequest({
handler,
path: target.path,
secret: "rotated-secret",
body: {
action: "list_flows",
},
});
expect(first.statusCode).toBe(200);
expect(second.statusCode).toBe(200);
expect(hoisted.resolveConfiguredSecretInputStringMock).toHaveBeenCalledTimes(1);
expect(second.statusCode).toBe(401);
expect(second.body).toBe("unauthorized");
expect(third.statusCode).toBe(200);
expect(hoisted.resolveConfiguredSecretInputStringMock).toHaveBeenCalledTimes(3);
});
it("creates flows through the bound session and scrubs owner metadata from responses", async () => {

View File

@@ -667,7 +667,6 @@ export function createTaskFlowWebhookRequestHandler(params: {
targetsByPath: Map<string, TaskFlowWebhookTarget[]>;
inFlightLimiter?: WebhookInFlightLimiter;
}): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
const secretByTarget = new WeakMap<TaskFlowWebhookTarget, Promise<string | undefined>>();
const rateLimiter = createFixedWindowRateLimiter({
windowMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs,
maxRequests: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests,
@@ -679,19 +678,19 @@ export function createTaskFlowWebhookRequestHandler(params: {
maxInFlightPerKey: WEBHOOK_IN_FLIGHT_DEFAULTS.maxInFlightPerKey,
maxTrackedKeys: WEBHOOK_IN_FLIGHT_DEFAULTS.maxTrackedKeys,
});
const resolveTargetSecret = (target: TaskFlowWebhookTarget): Promise<string | undefined> => {
const cached = secretByTarget.get(target);
if (cached) {
return cached;
const resolveTargetSecret = async (
target: TaskFlowWebhookTarget,
): Promise<string | undefined> => {
if (typeof target.secretInput === "string") {
return target.secretInput;
}
const pending = resolveConfiguredSecretInputString({
const resolved = await resolveConfiguredSecretInputString({
config: params.cfg,
env: process.env,
value: target.secretInput,
path: target.secretConfigPath,
}).then((resolved) => resolved.value);
secretByTarget.set(target, pending);
return pending;
});
return resolved.value;
};
return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {