1/**
2 * Renders a webhook task's prompt from the request that triggered it. The
3 * rendered prompt is the only thing the agent receives, so a user who wants
4 * the whole request writes `{{request}}` or `{{body}}` themselves.
5 *
6 * The syntax is deliberately just `{{path}}` lookups — no conditionals,
7 * defaults, filters or escaping — so it never grows into a template language:
8 *
9 * - `{{body.a.b.0}}` a field of a JSON or form-encoded body
10 * - `{{headers.name}}` a request header (case-insensitive)
11 * - `{{query.name}}` a URL query parameter
12 * - `{{body}}` the raw body text
13 * - `{{request}}` method, path, query, headers and body
14 *
15 * Strings and numbers render as text, objects and arrays as JSON. A path with
16 * no value renders empty and is reported in `missing`.
17 *
18 * Credential-looking headers and query parameters are redacted wherever the
19 * whole set renders (`{{request}}`, `{{headers}}`, `{{query}}`), as in the
20 * delivery log. Naming one (`{{headers.authorization}}`) gives its raw value.
21 */
22
23export interface WebhookRequest {
24 readonly method: string;
25 readonly path: string;
26 /** Raw query string without the leading `?`. */
27 readonly query: string;
28 /** Lowercased header names. */
29 readonly headers: Readonly<Record<string, string>>;
30 readonly bodyText: string;
31}
32
33export interface RenderedWebhookPrompt {
34 readonly prompt: string;
35 readonly missing: ReadonlyArray<string>;
36}
37
38/** Header and query parameter names that commonly carry credentials. */
39const CREDENTIAL_NAME =
40 /^(authorization|proxy-authorization|cookie|set-cookie)$|token|secret|signature|key|password|auth/i;
41const REDACTED = "[redacted]";
42
43export function redactHeaders(headers: Readonly<Record<string, string>>): Record<string, string> {
44 return Object.fromEntries(
45 Object.entries(headers).map(([name, value]) => [
46 name,
47 CREDENTIAL_NAME.test(name) ? REDACTED : value,
48 ]),
49 );
50}
51
52/** Redacts credential-named values in a raw query string, keeping the rest as sent. */
53export function redactQuery(query: string): string {
54 if (query === "") return query;
55 return query
56 .split("&")
57 .map((part) => {
58 const separator = part.indexOf("=");
59 if (separator === -1) return part;
60 const name = part.slice(0, separator);
61 let decoded = name;
62 try {
63 decoded = decodeURIComponent(name.replaceAll("+", " "));
64 } catch {
65 // A malformed escape is matched as sent.
66 }
67 return CREDENTIAL_NAME.test(decoded) ? `${name}=${REDACTED}` : part;
68 })
69 .join("&");
70}
71
72// Trim after matching so unclosed placeholders cannot backtrack over whitespace partitions.
73const PLACEHOLDER = /\{\{([^{}]*)\}\}/g;
74
75function parseBody(request: WebhookRequest): unknown {
76 const contentType = (request.headers["content-type"] ?? "").toLowerCase();
77 if (contentType.includes("application/x-www-form-urlencoded")) {
78 return Object.fromEntries(new URLSearchParams(request.bodyText));
79 }
80 // Senders are inconsistent about content types, so any body that parses
81 // as JSON is addressable.
82 try {
83 return JSON.parse(request.bodyText) as unknown;
84 } catch {
85 return undefined;
86 }
87}
88
89function lookup(root: unknown, segments: ReadonlyArray<string>): unknown {
90 let current = root;
91 for (const segment of segments) {
92 if (current === null || typeof current !== "object") return undefined;
93 if (!Object.hasOwn(current, segment)) return undefined;
94 current = (current as Record<string, unknown>)[segment];
95 }
96 return current;
97}
98
99function stringify(value: unknown): string {
100 if (typeof value === "string") return value;
101 if (typeof value === "number" || typeof value === "boolean") return String(value);
102 return JSON.stringify(value, null, 2);
103}
104
105function formatWebhookRequest(request: WebhookRequest): string {
106 const headerLines = Object.entries(redactHeaders(request.headers)).map(
107 ([name, value]) => `${name}: ${value}`,
108 );
109 const query = redactQuery(request.query);
110 return [
111 `${request.method} ${request.path}${query ? `?${query}` : ""}`,
112 ...headerLines,
113 "",
114 request.bodyText,
115 ].join("\n");
116}
117
118export function renderWebhookPrompt(
119 template: string,
120 request: WebhookRequest,
121): RenderedWebhookPrompt {
122 const missing: string[] = [];
123 let body: { parsed: unknown } | undefined;
124 const parsedBody = () => (body ??= { parsed: parseBody(request) }).parsed;
125
126 const resolve = (expression: string): unknown => {
127 const [root, ...segments] = expression.split(".");
128 switch (root) {
129 case "request":
130 return segments.length === 0 ? formatWebhookRequest(request) : undefined;
131 case "body":
132 return segments.length === 0 ? request.bodyText : lookup(parsedBody(), segments);
133 case "headers":
134 return segments.length === 0
135 ? redactHeaders(request.headers)
136 : request.headers[segments.join(".").toLowerCase()];
137 case "query": {
138 if (segments.length === 0) {
139 return Object.fromEntries(new URLSearchParams(redactQuery(request.query)));
140 }
141 return new URLSearchParams(request.query).get(segments.join(".")) ?? undefined;
142 }
143 default:
144 return undefined;
145 }
146 };
147
148 const prompt = template.replace(PLACEHOLDER, (_match, rawExpression: string) => {
149 const expression = rawExpression.trim();
150 const value = resolve(expression);
151 if (value === undefined || value === null) {
152 if (!missing.includes(expression)) missing.push(expression);
153 return "";
154 }
155 return stringify(value);
156 });
157 return { prompt, missing };
158}