Skip to content

Commit 1fdc08b

Browse files
committed
fix: prevent cross-task contamination and mutation side effects by deep-cloning persisted and incoming task data
1 parent ee23e9c commit 1fdc08b

2 files changed

Lines changed: 244 additions & 23 deletions

File tree

src/server/result_manager.ts

Lines changed: 73 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ import { ServerCallContext } from './context.js';
99
import { AgentExecutionEvent, assertUnreachableEvent } from './events/execution_event_bus.js';
1010
import { TaskStore } from './store.js';
1111

12+
/**
13+
* Tracks the in-flight task/message state for a single A2A request and
14+
* persists updates to the {@link TaskStore}.
15+
*
16+
* Mutation safety: every external object handed to this class (event
17+
* payloads, user messages) is deep-cloned via `structuredClone` before
18+
* being stored, and every object stored back into the task is likewise a
19+
* fresh clone. This isolates `ResultManager`'s internal state from
20+
* caller-side mutations of the same event objects (and vice versa). The
21+
* `TaskStore.load` / `TaskStore.save` boundary clones independently, so
22+
* this class is safe to combine with stores that share references.
23+
*/
1224
export class ResultManager {
1325
private readonly taskStore: TaskStore;
1426
private readonly serverCallContext: ServerCallContext;
@@ -23,7 +35,9 @@ export class ResultManager {
2335
}
2436

2537
public setContext(latestUserMessage: Message): void {
26-
this.latestUserMessage = latestUserMessage;
38+
// Clone so a caller mutating the message later (or reusing the object
39+
// across calls) can't perturb our internal copy.
40+
this.latestUserMessage = structuredClone(latestUserMessage);
2741
}
2842

2943
/**
@@ -33,7 +47,9 @@ export class ResultManager {
3347
public async processEvent(event: AgentExecutionEvent): Promise<void> {
3448
switch (event.kind) {
3549
case 'message': {
36-
this.finalMessageResult = event.data;
50+
// Final-result messages may be returned to callers verbatim, so
51+
// store a defensive copy.
52+
this.finalMessageResult = structuredClone(event.data);
3753
// If a message is received, it's usually the final result,
3854
// but we continue processing to ensure task state (if any) is also saved.
3955
// The ExecutionEventQueue will stop after a message event.
@@ -50,52 +66,66 @@ export class ResultManager {
5066
// Unlike status/artifact updates, receiving a Task event with no
5167
// prior persisted task is the normal create-flow, so we load
5268
// directly rather than going through `ensureTaskLoaded` (which
53-
// warns on misses).
54-
if (!this.currentTask && taskEvent.id) {
69+
// warns on misses). We also re-load if the in-memory task is for
70+
// a different id, otherwise we'd lose the persisted state for the
71+
// new task id.
72+
if ((!this.currentTask || this.currentTask.id !== taskEvent.id) && taskEvent.id) {
5573
const loaded = await this.taskStore.load(taskEvent.id, this.serverCallContext);
5674
if (loaded) {
5775
this.currentTask = loaded;
76+
} else if (this.currentTask && this.currentTask.id !== taskEvent.id) {
77+
// The previously-tracked task is unrelated to the incoming
78+
// event; drop it so we don't accidentally merge across ids.
79+
this.currentTask = undefined;
5880
}
5981
}
6082
const persistedTask =
6183
this.currentTask && this.currentTask.id === taskEvent.id ? this.currentTask : undefined;
6284

63-
const mergedTask: Task = { ...taskEvent };
85+
// Deep-clone the incoming Task so further executor mutations or
86+
// caller-side reuse of the same event object can't leak into our
87+
// state.
88+
const mergedTask: Task = structuredClone(taskEvent);
6489

6590
if (persistedTask) {
6691
// Preserve persisted history when the incoming Task event omits it.
6792
// If the incoming Task event carries its own history, treat it as
6893
// authoritative (the executor is responsible for what gets persisted
6994
// per §3.7).
7095
if ((!mergedTask.history || mergedTask.history.length === 0) && persistedTask.history) {
71-
mergedTask.history = [...persistedTask.history];
96+
mergedTask.history = structuredClone(persistedTask.history);
7297
}
7398

7499
// Merge artifacts: keep persisted artifacts and overlay any incoming
75100
// ones (matched by artifactId). Incoming wins for collisions; new
76101
// ones are appended.
77102
mergedTask.artifacts = this.mergeArtifacts(persistedTask.artifacts, taskEvent.artifacts);
78103

79-
// Merge metadata, incoming wins on key collisions.
104+
// Merge metadata, incoming wins on key collisions. structuredClone
105+
// each half so nested values can't be shared with either source.
80106
if (persistedTask.metadata || taskEvent.metadata) {
81107
mergedTask.metadata = {
82-
...(persistedTask.metadata ?? {}),
83-
...(taskEvent.metadata ?? {}),
108+
...structuredClone(persistedTask.metadata ?? {}),
109+
...structuredClone(taskEvent.metadata ?? {}),
84110
};
85111
}
86112
}
87113

88114
this.currentTask = mergedTask;
89115

90116
// Ensure the latest user message is in history if not already present.
117+
// `latestUserMessage` was already cloned in `setContext`, but clone
118+
// again so the same reference can't end up shared between the
119+
// history array and the `latestUserMessage` slot if the same
120+
// `ResultManager` is reused for multiple task events.
91121
if (this.latestUserMessage) {
92122
if (
93123
!this.currentTask.history?.find(
94124
(msg) => msg.messageId === this.latestUserMessage?.messageId
95125
)
96126
) {
97127
this.currentTask.history = [
98-
this.latestUserMessage,
128+
structuredClone(this.latestUserMessage),
99129
...(this.currentTask.history || []),
100130
];
101131
}
@@ -131,12 +161,14 @@ export class ResultManager {
131161
await this.ensureTaskLoaded(updateEvent.taskId, 'status update');
132162

133163
if (this.currentTask && this.currentTask.id === updateEvent.taskId) {
134-
this.currentTask.status = updateEvent.status;
164+
// Clone the incoming status (and its nested message) so caller-side
165+
// mutation of the original event payload can't drift our state.
166+
this.currentTask.status = structuredClone(updateEvent.status);
135167
const update = updateEvent.status?.message;
136168
if (update) {
137169
// Add message to history if not already present
138170
if (!this.currentTask.history?.find((msg) => msg.messageId === update.messageId)) {
139-
this.currentTask.history = [...(this.currentTask.history || []), update];
171+
this.currentTask.history = [...(this.currentTask.history || []), structuredClone(update)];
140172
}
141173
}
142174
await this.saveCurrentTask();
@@ -158,22 +190,27 @@ export class ResultManager {
158190
);
159191
if (existingArtifactIndex !== -1) {
160192
if (artifactEvent.append) {
161-
// Basic append logic, assuming parts are compatible
162-
// More sophisticated merging might be needed for specific part types
193+
// Basic append logic, assuming parts are compatible.
194+
// Clone incoming parts/metadata so the persisted artifact owns
195+
// its own deep copies and the event payload can be reused
196+
// safely by the executor.
163197
const existingArtifact = this.currentTask.artifacts[existingArtifactIndex];
164-
existingArtifact.parts = [...(existingArtifact.parts || []), ...(artifact.parts || [])];
198+
existingArtifact.parts = [
199+
...(existingArtifact.parts || []),
200+
...structuredClone(artifact.parts || []),
201+
];
165202
if (artifact.description) existingArtifact.description = artifact.description;
166203
if (artifact.name) existingArtifact.name = artifact.name;
167204
if (artifact.metadata)
168205
existingArtifact.metadata = {
169206
...existingArtifact.metadata,
170-
...artifact.metadata,
207+
...structuredClone(artifact.metadata),
171208
};
172209
} else {
173-
this.currentTask.artifacts[existingArtifactIndex] = artifact;
210+
this.currentTask.artifacts[existingArtifactIndex] = structuredClone(artifact);
174211
}
175212
} else {
176-
this.currentTask.artifacts.push(artifact);
213+
this.currentTask.artifacts.push(structuredClone(artifact));
177214
}
178215
await this.saveCurrentTask();
179216
}
@@ -184,28 +221,34 @@ export class ResultManager {
184221
* are retained and overlaid by any incoming artifact with the same id;
185222
* artifacts only present in the incoming list are appended. Order is
186223
* preserved (persisted first, then any newly-introduced incoming artifacts).
224+
*
225+
* Every artifact in the returned array is a fresh deep copy so subsequent
226+
* in-place mutations (e.g. by `applyArtifactUpdate`) can't leak into the
227+
* original `persisted` / `incoming` arrays the caller still holds.
187228
*/
188229
private mergeArtifacts(
189230
persisted: Artifact[] | undefined,
190231
incoming: Artifact[] | undefined
191232
): Artifact[] {
192233
if (!persisted || persisted.length === 0) {
193-
return incoming ? [...incoming] : [];
234+
return incoming ? structuredClone(incoming) : [];
194235
}
195236
if (!incoming || incoming.length === 0) {
196-
return [...persisted];
237+
return structuredClone(persisted);
197238
}
198239

199240
const incomingById = new Map<string, Artifact>();
200241
for (const art of incoming) {
201242
incomingById.set(art.artifactId, art);
202243
}
203244

204-
const merged: Artifact[] = persisted.map((art) => incomingById.get(art.artifactId) ?? art);
245+
const merged: Artifact[] = persisted.map((art) =>
246+
structuredClone(incomingById.get(art.artifactId) ?? art)
247+
);
205248
const seenIds = new Set(persisted.map((art) => art.artifactId));
206249
for (const art of incoming) {
207250
if (!seenIds.has(art.artifactId)) {
208-
merged.push(art);
251+
merged.push(structuredClone(art));
209252
}
210253
}
211254
return merged;
@@ -220,6 +263,11 @@ export class ResultManager {
220263
/**
221264
* Gets the final result, which could be a Message or a Task.
222265
* This should be called after the event stream has been fully processed.
266+
*
267+
* Returns a reference to the internally-tracked object (not a clone) so
268+
* that downstream callers like `_applyHistoryLengthSemantics` can apply
269+
* in-place edits. Safe because `ResultManager` instances are scoped to a
270+
* single request and discarded immediately after this is called.
223271
* @returns The final Message or the current Task.
224272
*/
225273
public getFinalResult(): Message | Task | undefined {
@@ -232,6 +280,9 @@ export class ResultManager {
232280
/**
233281
* Gets the task currently being managed by this ResultManager instance.
234282
* This task could be one that was started with or one created during agent execution.
283+
*
284+
* Returns the internal reference (see {@link getFinalResult} for the
285+
* rationale).
235286
* @returns The current Task or undefined if no task is active.
236287
*/
237288
public getCurrentTask(): Task | undefined {

0 commit comments

Comments
 (0)