Skip to content

Commit 8e7ea16

Browse files
author
Zoo (VP)
committed
fix(error-interception): guard WeakMap accessors against non-object task keys
getTaskState guarded only falsy keys and the module-level getTaskErrorState/hasTaskErrorState had no guard at all, so a primitive non-null key (e.g. a string taskId, an easy mistake since InterceptorOptions.taskId is a string) still threw TypeError on WeakMap.set(). Both accessors now fail open: invalid keys get an ephemeral state that is never stored, matching the existing fail-open philosophy.
1 parent ed08945 commit 8e7ea16

4 files changed

Lines changed: 99 additions & 8 deletions

File tree

src/core/tools/error-interception/TaskErrorState.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,12 +143,28 @@ export class TaskErrorState {
143143
*/
144144
const taskStates = new WeakMap<object, TaskErrorState>()
145145

146+
/**
147+
* Returns true when the argument can be used as a WeakMap key. Primitives
148+
* (including string taskIds, an easy mistake) and null/undefined cannot.
149+
*/
150+
function isWeakMapKey(task: object): boolean {
151+
return !!task && (typeof task === "object" || typeof task === "function")
152+
}
153+
146154
/**
147155
* Returns the persistent TaskErrorState for the given Task, creating it on
148156
* first access. The Task argument is typed as object to keep this module
149157
* decoupled from the concrete Task class.
158+
*
159+
* Non-object keys (null/undefined/primitives) fail open with an ephemeral
160+
* instance instead of throwing TypeError from WeakMap.set(); ephemeral
161+
* instances are never stored, so counters do not persist across calls for
162+
* invalid keys.
150163
*/
151164
export function getTaskErrorState(task: object): TaskErrorState {
165+
if (!isWeakMapKey(task)) {
166+
return new TaskErrorState()
167+
}
152168
let state = taskStates.get(task)
153169
if (!state) {
154170
state = new TaskErrorState()
@@ -160,8 +176,12 @@ export function getTaskErrorState(task: object): TaskErrorState {
160176
/**
161177
* Returns true when a TaskErrorState already exists for the given Task,
162178
* without materializing a new instance. Use this to guard reset paths that
163-
* must not create empty state as a side effect.
179+
* must not create empty state as a side effect. Returns false for keys that
180+
* cannot be stored in the WeakMap.
164181
*/
165182
export function hasTaskErrorState(task: object): boolean {
183+
if (!isWeakMapKey(task)) {
184+
return false
185+
}
166186
return taskStates.has(task)
167187
}

src/core/tools/error-interception/ToolErrorInterceptor.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,16 @@ export class ToolErrorInterceptor {
9090
* Creates or returns existing per-task state. Uses a WeakMap keyed by the
9191
* Task object so state is discarded when the task is garbage collected.
9292
*
93-
* When `task` is null or undefined (invalid WeakMap key), returns an
94-
* ephemeral default state to satisfy the fail-open philosophy rather than
95-
* throwing TypeError from WeakMap.set().
93+
* When `task` is not a valid WeakMap key (null, undefined, or a primitive
94+
* such as a string taskId — an easy mistake since InterceptorOptions.taskId
95+
* is a string), returns an ephemeral default state to satisfy the fail-open
96+
* philosophy rather than throwing TypeError from WeakMap.set().
9697
*/
9798
public getTaskState(task: object): InterceptorTaskState {
98-
// WeakMap keys must be objects; null/undefined are invalid and would
99-
// throw TypeError on .set(). Fail-open: return an ephemeral default
100-
// state so callers can proceed without crashing.
101-
if (!task) {
99+
// WeakMap keys must be objects (or functions); primitives are invalid
100+
// and would throw TypeError on .set(). Fail-open: return an ephemeral
101+
// default state so callers can proceed without crashing.
102+
if (!task || (typeof task !== "object" && typeof task !== "function")) {
102103
return { categoryCounts: new Map(), shellCircuitOpen: false }
103104
}
104105
let taskState = this.state.perTask.get(task)

src/core/tools/error-interception/__tests__/TaskErrorState.spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,42 @@ describe("hasTaskErrorState", () => {
169169
expect(hasTaskErrorState(taskB)).toBe(false)
170170
})
171171
})
172+
173+
describe("non-object key guards", () => {
174+
// Double assertions are required below to simulate the caller mistake these
175+
// guards protect against: passing a primitive (e.g. a string taskId) or
176+
// null/undefined where a Task object is expected. There is no typed way to
177+
// express that mistake.
178+
179+
it("getTaskErrorState returns an ephemeral state for a primitive key instead of throwing", () => {
180+
const notATask = "task-id" as unknown as object
181+
expect(() => getTaskErrorState(notATask)).not.toThrow()
182+
// Ephemeral: nothing is stored in the WeakMap for invalid keys.
183+
expect(hasTaskErrorState(notATask)).toBe(false)
184+
})
185+
186+
it("getTaskErrorState returns a fresh ephemeral instance per call for invalid keys", () => {
187+
const notATask = "task-id" as unknown as object
188+
expect(getTaskErrorState(notATask)).not.toBe(getTaskErrorState(notATask))
189+
})
190+
191+
it("getTaskErrorState tolerates null and undefined keys", () => {
192+
expect(() => getTaskErrorState(null as unknown as object)).not.toThrow()
193+
expect(() => getTaskErrorState(undefined as unknown as object)).not.toThrow()
194+
})
195+
196+
it("hasTaskErrorState returns false for primitive and nullish keys", () => {
197+
expect(hasTaskErrorState("task-id" as unknown as object)).toBe(false)
198+
expect(hasTaskErrorState(42 as unknown as object)).toBe(false)
199+
expect(hasTaskErrorState(null as unknown as object)).toBe(false)
200+
expect(hasTaskErrorState(undefined as unknown as object)).toBe(false)
201+
})
202+
203+
it("still works normally for object keys after guarded calls", () => {
204+
const task = { id: "task-after-guard" }
205+
getTaskErrorState("task-id" as unknown as object).incrementOccurrence("PARAM_MISSING")
206+
expect(getTaskErrorState(task).getOccurrence("PARAM_MISSING")).toBe(0)
207+
getTaskErrorState(task).incrementOccurrence("PARAM_MISSING")
208+
expect(getTaskErrorState(task).getOccurrence("PARAM_MISSING")).toBe(1)
209+
})
210+
})

src/core/tools/error-interception/__tests__/ToolErrorInterceptor.spec.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,37 @@ describe("ToolErrorInterceptor", () => {
607607
})
608608
})
609609

610+
describe("getTaskState non-object key guard", () => {
611+
// Double assertions are required below to simulate the caller mistake
612+
// this guard protects against: passing a primitive (e.g. the string
613+
// InterceptorOptions.taskId) where a Task object is expected. There is
614+
// no typed way to express that mistake.
615+
616+
it("returns an ephemeral state for a string key instead of throwing", () => {
617+
const interceptor = createToolErrorInterceptor()
618+
const notATask = "task-123" as unknown as object
619+
expect(() => interceptor.getTaskState(notATask)).not.toThrow()
620+
// Ephemeral: nothing is persisted for invalid keys, so each call
621+
// returns a fresh state container.
622+
expect(interceptor.getTaskState(notATask)).not.toBe(interceptor.getTaskState(notATask))
623+
})
624+
625+
it("returns an ephemeral state for null, undefined, and numeric keys", () => {
626+
const interceptor = createToolErrorInterceptor()
627+
expect(() => interceptor.getTaskState(null as unknown as object)).not.toThrow()
628+
expect(() => interceptor.getTaskState(undefined as unknown as object)).not.toThrow()
629+
expect(() => interceptor.getTaskState(42 as unknown as object)).not.toThrow()
630+
})
631+
632+
it("ephemeral state does not leak into real task state", () => {
633+
const interceptor = createToolErrorInterceptor()
634+
const notATask = "task-123" as unknown as object
635+
interceptor.getTaskState(notATask).categoryCounts.set("SHELL_INTEGRATION", 5)
636+
const task = createTask()
637+
expect(interceptor.getTaskState(task).categoryCounts.get("SHELL_INTEGRATION")).toBeUndefined()
638+
})
639+
})
640+
610641
describe("MCP branch compatibility", () => {
611642
it("forwards the feedbackImages second argument unchanged", () => {
612643
const interceptor = createToolErrorInterceptor()

0 commit comments

Comments
 (0)