Skip to content

Commit 601ebc3

Browse files
Harden task error-forwarder listener method reads
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent 2bfd612 commit 601ebc3

3 files changed

Lines changed: 103 additions & 3 deletions

File tree

currentState.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,9 @@ HyperAgent exposes a TypeScript SDK for browser automation with three primary pa
206206
- Hardened frame-graph debug artifact capture in agent loop:
207207
- `writeFrameGraphSnapshot()` now guards trap-prone frame-manager `setDebug` calls and still proceeds with frame graph serialization.
208208
- Added regression coverage to ensure debug-setter traps do not downgrade into "Failed to write frame graph" failures.
209+
- Hardened task error-forwarder listener registration:
210+
- `HyperAgent` now resolves `errorEmitter.on`/`off` methods through trap-safe, receiver-bound helpers before invoking task-scoped forwarding hooks.
211+
- Added regression coverage proving async task execution still succeeds (with sanitized warnings) when `errorEmitter.on` getter traps.
209212
- Refreshed remaining staged-flow wording in the CDP deep dive around OOPIF discovery to describe current execution-context sync progression without stale "Need Phase 4" phrasing.
210213
- Hardened CDP command dispatch in Playwright session adapter:
211214
- `PlaywrightSessionAdapter.send()` now guards trap-prone `session.send` method reads and wraps sync send failures with sanitized/diagnostic context.

src/agent/__tests__/hyperagent-constructor.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,6 +1011,72 @@ describe("HyperAgent constructor and task controls", () => {
10111011
}
10121012
});
10131013

1014+
it("continues async task execution when errorEmitter.on getter traps", async () => {
1015+
const mockedRunAgentTask = jest.mocked(runAgentTask);
1016+
mockedRunAgentTask.mockImplementation(async (_, state) => ({
1017+
taskId: state.id,
1018+
status: TaskStatus.COMPLETED,
1019+
steps: [],
1020+
output: "done",
1021+
actionCache: {
1022+
taskId: state.id,
1023+
createdAt: new Date().toISOString(),
1024+
status: TaskStatus.COMPLETED,
1025+
steps: [],
1026+
},
1027+
}));
1028+
1029+
const agent = new HyperAgent({
1030+
llm: createMockLLM(),
1031+
debug: true,
1032+
});
1033+
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
1034+
const internalAgent = agent as unknown as {
1035+
errorEmitter: {
1036+
listenerCount: (event: string) => number;
1037+
};
1038+
taskErrorForwarders: Map<string, (error: Error) => void>;
1039+
};
1040+
const baseEmitter = internalAgent.errorEmitter;
1041+
internalAgent.errorEmitter = new Proxy(baseEmitter as object, {
1042+
get: (target, property, receiver) => {
1043+
if (property === "on") {
1044+
throw new Error(`errorEmitter on trap\u0000\n${"x".repeat(10_000)}`);
1045+
}
1046+
const value = Reflect.get(target, property, receiver);
1047+
if (typeof value === "function") {
1048+
return value.bind(target);
1049+
}
1050+
return value;
1051+
},
1052+
}) as unknown as typeof internalAgent.errorEmitter;
1053+
1054+
const fakePage = {} as unknown as Page;
1055+
try {
1056+
const task = await agent.executeTaskAsync(
1057+
"on getter trap task",
1058+
undefined,
1059+
fakePage
1060+
);
1061+
await expect(task.result).resolves.toMatchObject({
1062+
status: TaskStatus.COMPLETED,
1063+
});
1064+
expect(internalAgent.taskErrorForwarders.size).toBe(0);
1065+
1066+
const warningLine = warnSpy.mock.calls
1067+
.map((call) => String(call[0] ?? ""))
1068+
.find((line) =>
1069+
line.includes("Failed to register task-scoped error listener")
1070+
);
1071+
expect(warningLine).toBeDefined();
1072+
expect(warningLine).toContain("[truncated");
1073+
expect(warningLine).not.toContain("\u0000");
1074+
expect(warningLine).not.toContain("\n");
1075+
} finally {
1076+
warnSpy.mockRestore();
1077+
}
1078+
});
1079+
10141080
it("surfaces HyperagentTaskError without requiring error listeners", async () => {
10151081
const mockedRunAgentTask = jest.mocked(runAgentTask);
10161082
mockedRunAgentTask.mockRejectedValue(new Error("boom without listeners"));

src/agent/index.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,34 @@ export class HyperAgent<T extends BrowserProviders = "Local"> {
494494
return createdPage as Page;
495495
}
496496

497+
private readErrorEmitterMethodOrThrow(
498+
methodName: "on" | "off"
499+
): (this: ErrorEmitter, eventName: string, listener: (error: Error) => void) => void {
500+
let method: unknown;
501+
try {
502+
method = (
503+
this.errorEmitter as ErrorEmitter & {
504+
on?: unknown;
505+
off?: unknown;
506+
}
507+
)[methodName];
508+
} catch (error) {
509+
throw new Error(
510+
`failed to read errorEmitter.${methodName}: ${this.formatLifecycleDiagnostic(
511+
error
512+
)}`
513+
);
514+
}
515+
if (typeof method !== "function") {
516+
throw new Error(`errorEmitter.${methodName} is unavailable`);
517+
}
518+
return method as (
519+
this: ErrorEmitter,
520+
eventName: string,
521+
listener: (error: Error) => void
522+
) => void;
523+
}
524+
497525
private async startBrowserProvider(): Promise<Browser> {
498526
const startMethod = this.safeReadField(this.browserProvider, "start");
499527
if (typeof startMethod !== "function") {
@@ -741,7 +769,8 @@ export class HyperAgent<T extends BrowserProviders = "Local"> {
741769
return;
742770
}
743771
try {
744-
this.errorEmitter.off("error", forwarder);
772+
const offMethod = this.readErrorEmitterMethodOrThrow("off");
773+
offMethod.call(this.errorEmitter, "error", forwarder);
745774
} catch {
746775
// no-op
747776
}
@@ -1768,13 +1797,15 @@ export class HyperAgent<T extends BrowserProviders = "Local"> {
17681797
};
17691798
let listenerAttached = false;
17701799
try {
1771-
this.errorEmitter.on("error", onTaskError);
1800+
const onMethod = this.readErrorEmitterMethodOrThrow("on");
1801+
onMethod.call(this.errorEmitter, "error", onTaskError);
17721802
listenerAttached = true;
17731803
this.taskErrorForwarders.set(taskId, onTaskError);
17741804
} catch (error) {
17751805
if (listenerAttached) {
17761806
try {
1777-
this.errorEmitter.off("error", onTaskError);
1807+
const offMethod = this.readErrorEmitterMethodOrThrow("off");
1808+
offMethod.call(this.errorEmitter, "error", onTaskError);
17781809
} catch {
17791810
// no-op
17801811
}

0 commit comments

Comments
 (0)