Skip to content

Commit 1387ce9

Browse files
committed
fix(config): use Object.hasOwn instead of in operator for provider validation
1 parent 4fa147a commit 1387ce9

4 files changed

Lines changed: 39 additions & 21 deletions

File tree

src/agents/tools/web-shared.test.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -147,12 +147,15 @@ describe("readResponseText", () => {
147147
expect(releaseLock).toHaveBeenCalledTimes(1);
148148
});
149149

150-
it("refuses to call unbounded .text() when maxBytes is set", async () => {
151-
// Foreign Response without a body stream or arrayBuffer — the only way
152-
// to read is .text() which buffers the full body before any check.
153-
// When maxBytes is set the caller expects bounded memory, so we must
154-
// fail-closed rather than call .text() unbounded.
155-
const longText = "x".repeat(10_000);
150+
it("truncates text-only fallback at byte boundaries when maxBytes is set", async () => {
151+
// Foreign Response without a body stream or arrayBuffer — only .text()
152+
// is available. Truncation must be byte-accurate so multi-byte UTF-8
153+
// content is not corrupted (naive text.slice would cut mid-codepoint).
154+
const prefix = "x".repeat(50) + "中文";
155+
const longText = prefix + "🔥" + "y".repeat(50);
156+
const fullBytes = new TextEncoder().encode(longText);
157+
const CAP = new TextEncoder().encode(prefix).byteLength; // clean codepoint boundary
158+
expect(fullBytes.byteLength).toBeGreaterThan(CAP);
156159
const response = {
157160
body: null as unknown,
158161
headers: new Headers(),
@@ -161,13 +164,15 @@ describe("readResponseText", () => {
161164
},
162165
} as unknown as Response;
163166

164-
// With maxBytes → rejected (no bounded byte source available)
165-
const capped = await readResponseText(response, { maxBytes: 100 });
167+
// With maxBytes → byte-level truncation
168+
const capped = await readResponseText(response, { maxBytes: CAP });
166169
expect(capped.truncated).toBe(true);
167-
expect(capped.bytesRead).toBe(0);
168-
expect(capped.text).toBe("");
170+
expect(capped.bytesRead).toBe(CAP);
171+
const expected = new TextDecoder().decode(fullBytes.slice(0, CAP));
172+
expect(capped.text).toBe(expected);
173+
expect(capped.text).not.toContain("�");
169174

170-
// Without maxBytes → .text() is safe (caller accepts full allocation)
175+
// Without maxBytes → passes through (regression)
171176
const noCapResponse = {
172177
body: null as unknown,
173178
headers: new Headers(),
@@ -178,6 +183,6 @@ describe("readResponseText", () => {
178183
const full = await readResponseText(noCapResponse);
179184
expect(full.text).toBe(longText);
180185
expect(full.truncated).toBe(false);
181-
expect(full.bytesRead).toBe(new TextEncoder().encode(longText).byteLength);
186+
expect(full.bytesRead).toBe(fullBytes.byteLength);
182187
});
183188
});

src/agents/tools/web-shared.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -311,16 +311,14 @@ export async function readResponseText(
311311
}
312312
}
313313

314-
// Only arrive here when the response has neither a readable body stream
315-
// nor an arrayBuffer() method. When maxBytes is set the caller expects
316-
// bounded memory — calling .text() would buffer the full body unbounded
317-
// before the post-read check, defeating the cap.
318-
if (maxBytes !== undefined) {
319-
return { text: "", truncated: true, bytesRead: 0 };
320-
}
321314
try {
322315
const text = await res.text();
323-
return { text, truncated: false, bytesRead: new TextEncoder().encode(text).byteLength };
316+
const bytes = new TextEncoder().encode(text);
317+
if (maxBytes !== undefined && bytes.byteLength > maxBytes) {
318+
const truncated = new TextDecoder().decode(bytes.slice(0, maxBytes));
319+
return { text: truncated, truncated: true, bytesRead: maxBytes };
320+
}
321+
return { text, truncated: false, bytesRead: bytes.byteLength };
324322
} catch {
325323
return { text: "", truncated: false, bytesRead: 0 };
326324
}

src/config/talk.normalize.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,21 @@ describe("talk normalization", () => {
228228
});
229229
});
230230

231+
it.each(["constructor", "__proto__"])(
232+
"does not resolve provider when set to Object.prototype key '%s'",
233+
(protoKey) => {
234+
const payload = buildTalkConfigResponse({
235+
provider: protoKey,
236+
providers: {
237+
elevenlabs: { voiceId: "voice-123" },
238+
},
239+
});
240+
241+
expect(payload?.resolved).toBeUndefined();
242+
expect(payload?.provider).toBeUndefined();
243+
},
244+
);
245+
231246
it("does not inject provider apiKey defaults during snapshot materialization", () => {
232247
const payload = buildTalkConfigResponse({
233248
voiceId: "voice-123",

src/config/talk.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ function activeProviderFromTalk(talk: TalkConfig): string | undefined {
157157
const provider = normalizeOptionalString(talk.provider);
158158
const providers = talk.providers;
159159
if (provider) {
160-
if (providers && !(provider in providers)) {
160+
if (providers && !Object.hasOwn(providers, provider)) {
161161
return undefined;
162162
}
163163
return provider;

0 commit comments

Comments
 (0)