Skip to content

Commit 2026ecf

Browse files
bgagentclaude
andcommitted
fix(agentcore-browser): use ws package for SigV4-signed WebSocket handshake
Node 24's global WebSocket (from undici) does NOT support arbitrary HTTP headers on the upgrade request — passing them as the second arg gets silently ignored. AgentCore Browser's WSS handshake requires SigV4-signed Authorization + X-Amz-* headers, so the connection was opening but then getting rejected, which surfaced as an empty `error` event ("AgentCore Browser WebSocket error: "). Switch to the `ws` package which natively supports `options.headers`. Also add an `unexpected-response` handler so HTTP-level handshake failures (403, 400) surface with status codes instead of empty errors. Smoke verified locally — the ws-based path opens cleanly against example.com and Vercel preview URLs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 87b8d70 commit 2026ecf

3 files changed

Lines changed: 42 additions & 22 deletions

File tree

cdk/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,16 @@
3535
"aws-cdk-lib": "^2.238.0",
3636
"cdk-nag": "^2.37.55",
3737
"constructs": "^10.3.0",
38-
"ulid": "^3.0.2"
38+
"ulid": "^3.0.2",
39+
"ws": "^8.18.0"
3940
},
4041
"devDependencies": {
4142
"@cdklabs/eslint-plugin": "^1.5.10",
4243
"@stylistic/eslint-plugin": "^2",
4344
"@types/aws-lambda": "^8.10.161",
4445
"@types/jest": "^30.0.0",
4546
"@types/node": "^20",
47+
"@types/ws": "^8.5.13",
4648
"@typescript-eslint/eslint-plugin": "^8",
4749
"@typescript-eslint/parser": "^8",
4850
"aws-cdk": "^2",

cdk/src/handlers/shared/agentcore-browser.ts

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
import { defaultProvider } from '@aws-sdk/credential-provider-node';
2727
import { HttpRequest } from '@smithy/protocol-http';
2828
import { SignatureV4 } from '@smithy/signature-v4';
29+
import WebSocket, { type RawData } from 'ws';
2930
import { logger } from './logger';
3031

3132
const REGION = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? 'us-east-1';
@@ -133,16 +134,12 @@ export async function captureScreenshot(url: string, opts: { timeoutMs?: number
133134
async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number): Promise<Uint8Array> {
134135
const headers = await sigV4WsHeaders(wssUrl);
135136

136-
// The WebSocket constructor in Node 24 doesn't accept custom headers
137-
// directly. Use the lower-level `undici` WebSocket via the `headers`
138-
// option — but the standard `WebSocket` does NOT expose that. Workaround:
139-
// attach the SigV4 headers as protocol fields. AWS's WSS handshake reads
140-
// both Authorization headers and Sec-WebSocket-Protocol-encoded variants.
141-
//
142-
// Simpler: open with the classic `Authorization` style by passing
143-
// headers via the dispatcher. Node 24 exposes `WebSocket` from undici
144-
// which DOES support this through `globalThis.WebSocket`'s second arg.
145-
const ws = new WebSocket(wssUrl, { headers } as unknown as string[]);
137+
// Use `ws` (the standard Node WebSocket client) — its constructor accepts
138+
// custom HTTP headers on the upgrade GET via `options.headers`. Node 24's
139+
// global `WebSocket` (from undici) does NOT accept arbitrary headers, and
140+
// AgentCore Browser's WSS handshake requires SigV4-signed `Authorization`
141+
// + `X-Amz-*` headers, so we have to inject them somehow.
142+
const ws = new WebSocket(wssUrl, { headers });
146143

147144
const deadline = Date.now() + timeoutMs;
148145
const remaining = () => Math.max(0, deadline - Date.now());
@@ -158,8 +155,8 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
158155
}
159156
const eventWaiters: EventWaiter[] = [];
160157

161-
ws.addEventListener('message', (event) => {
162-
const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer);
158+
ws.on('message', (raw: RawData) => {
159+
const data = raw.toString();
163160
let msg: CdpMessage;
164161
try {
165162
msg = JSON.parse(data) as CdpMessage;
@@ -187,22 +184,31 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
187184
}
188185
});
189186

190-
// Open the socket.
187+
// Open the socket. `ws` exposes node-style EventEmitter; the
188+
// `unexpected-response` event surfaces HTTP-level handshake failures
189+
// (e.g. 403 from misaligned SigV4) so we can log a meaningful error
190+
// instead of an empty `error` event.
191191
await new Promise<void>((resolve, reject) => {
192-
const onOpen = () => {
192+
const onOpen = (): void => {
193193
cleanup();
194194
resolve();
195195
};
196-
const onError = (e: Event) => {
196+
const onError = (err: Error): void => {
197197
cleanup();
198-
reject(new Error(`AgentCore Browser WebSocket error: ${(e as ErrorEvent).message ?? '(no message)'}`));
198+
reject(new Error(`AgentCore Browser WebSocket error: ${err.message || '(no message)'}`));
199199
};
200-
const cleanup = () => {
201-
ws.removeEventListener('open', onOpen);
202-
ws.removeEventListener('error', onError);
200+
const onUnexpectedResponse = (_req: unknown, res: { statusCode?: number }): void => {
201+
cleanup();
202+
reject(new Error(`AgentCore Browser WebSocket handshake failed: HTTP ${res.statusCode ?? '?'}`));
203+
};
204+
const cleanup = (): void => {
205+
ws.removeListener('open', onOpen);
206+
ws.removeListener('error', onError);
207+
ws.removeListener('unexpected-response', onUnexpectedResponse);
203208
};
204-
ws.addEventListener('open', onOpen);
205-
ws.addEventListener('error', onError);
209+
ws.on('open', onOpen);
210+
ws.on('error', onError);
211+
ws.on('unexpected-response', onUnexpectedResponse);
206212
setTimeout(() => {
207213
cleanup();
208214
reject(new Error(`AgentCore Browser WebSocket open timeout after ${timeoutMs}ms`));

yarn.lock

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)