diff --git a/.env.example b/.env.example index 7bb8006e..1f2cf42e 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,9 @@ GOOGLE_CONNECTOR_UID= # optional click-to-message shortcut in the workspace. LINQ_CONNECTOR= LINQ_PHONE_NUMBER= +# Optional paid public-web research through Parallel Responses API. +# Leave unset to disable web_research. Never expose this as NEXT_PUBLIC_*. +PARALLEL_API_KEY= # Development benchmarks only (pnpm bench:browser). BROWSER_BENCH_LABEL=self-hosted BROWSER_BENCH_REPETITIONS=1 diff --git a/README.md b/README.md index 0f453ddb..d0915f14 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,37 @@ Gotchas: - Sending email and creating confirmed calendar events always require approval. Calendar events with attendees send Google invitations. +## Optional Parallel web research + +Set `PARALLEL_API_KEY` on the server to enable `web_research`, a tool that uses +the [Parallel Responses API](https://docs.parallel.ai/responses-api/responses-quickstart) +to return a researched answer with source links. Leave it unset to keep the +tool disabled. Existing search tools and the browser worker are unchanged. + +Get a key from [Parallel](https://platform.parallel.ai), add it to `.env.local` +and restart local development. For Vercel, set it separately in each environment +that should use research and redeploy. This is a paid API using the operator's +account for all authenticated users, separate from the free Search MCP service. +Never put the key in chat or a `NEXT_PUBLIC_` variable. + +Give the tool a complete public-web question in `query`, up to 20,000 characters. +`effort` defaults to `low`; `medium` and `high` allow more extensive research. +Only the explicit question, chosen effort and optional follow-up ID are sent, +not conversation history, files, screenshots, vault contents or Google data. +Anything included in the question is sent to Parallel, so keep private data out. +Answers include a `sources` list when citations are available. + +To continue the same investigation, pass its returned `response_id` as +`previous_response_id`. Omit that argument for independent research. Saved +context is best-effort and can become unavailable; Zero Data Retention (ZDR) +accounts cannot continue by ID. Failed follow-ups are not silently restarted +without context. Retention follows the Parallel account's policy. + +Calls wait up to 120 seconds and the integration does not retry them. Cancelling +stops local waiting, but work already accepted by Parallel may still complete +and be billed. Eve can rerun a step interrupted before its result is saved, so +this does not guarantee exactly-once billing after a process failure. + ## Local development Docker Desktop (or another Docker Compose installation) is required. Configure diff --git a/agent/instructions.md b/agent/instructions.md index 69b33a9c..2ca30957 100644 --- a/agent/instructions.md +++ b/agent/instructions.md @@ -34,7 +34,9 @@ The main conversation is the control plane. Coordinate the user's work there, de - Answer conversational, clarifying, and quick informational requests directly without delegation when they do not require a browser. - Persist through recoverable failures. Change tactics when a site, source, or tool path fails instead of giving up after the first attempt. - Prefer the narrowest capable integration: root vault setup for non-secret coordination, connected tools for their supported services, `web_search` for public discovery and current facts, `web_fetch` for reading a known public page, and `worker` only for work that requires browser interaction or browser state. -- Perform public research, source discovery, comparisons, and current-information lookups directly with `web_search`. Never delegate a search-only task or use a browser to visit a search engine or browse search-result pages. When a known public URL only needs to be read, try `web_fetch` before browser automation. +- Perform public research, source discovery, comparisons, and current-information lookups directly with `web_search`, or use `web_research` when available for a synthesized answer as described below. Never delegate a search-only task or use a browser to visit a search engine or browse search-result pages. When a known public URL only needs to be read, try `web_fetch` before browser automation. +- When `web_research` is available, use it for a public-web question that needs a researched answer with source links. Give it the complete question and constraints, keep private account data and secrets out, and preserve the returned source links in your answer. Use low effort for quick facts and higher effort only when the question needs more research. Existing search tools remain useful when you need individual sources rather than a synthesized answer. +- Use a `previous_response_id` returned in this conversation only when continuing the same research; omit it for an independent question. Do not retry research errors automatically or silently restart a failed follow-up without its context. Saved context may be unavailable, and ZDR accounts cannot use follow-ups. - Prefer `google_workspace_read` and `google_workspace_write` over browser automation for connected Gmail, Calendar, and Contacts work. Never ask for Google tokens or credentials in chat. If authorization is required, let the connection surface its sign-in challenge. - Use exact Gmail message IDs for reversible inbox updates. Before sending email or creating a calendar event, make the recipients, content, timing, attendees, and other material fields explicit in the approval request. - Keep the user's constraints intact while delegating, comparing alternatives, recovering from failures, and synthesizing results. diff --git a/agent/tools/web_research.ts b/agent/tools/web_research.ts new file mode 100644 index 00000000..00bfb05d --- /dev/null +++ b/agent/tools/web_research.ts @@ -0,0 +1,195 @@ +import { defineDynamic, defineTool } from "eve/tools"; +import { z } from "zod"; +import { env } from "@/env"; + +const responseIdSchema = z.string().min(1).regex(/^\S+$/u); + +const inputSchema = z.object({ + query: z + .string() + .min(1) + .refine((value) => value.trim().length > 0, "Provide a research question.") + .refine( + // Match the API's Unicode code points, not UTF-16 code units. + (value) => Array.from(value).length <= 20_000, + "The question must be at most 20,000 characters." + ) + .describe( + "The complete public-web question, including dates, units and constraints. No conversation history is sent automatically. Never include secrets or private account data. Maximum 20,000 characters." + ), + effort: z + .enum(["low", "medium", "high"]) + .default("low") + .describe( + "Use low for quick facts, medium for comparisons, high for extensive research. Each call is billed by Parallel." + ), + previous_response_id: responseIdSchema + .optional() + .describe( + "The response_id returned by this tool in this conversation, only when continuing that investigation. Omit for independent research. Saved context may be unavailable; ZDR accounts cannot use follow-ups." + ), +}); + +const responseSchema = z.object({ + status: z.literal("completed"), + id: z.unknown().optional(), + output: z.array( + z.object({ + type: z.string(), + content: z + .array( + z.object({ + type: z.string(), + text: z.string().optional(), + annotations: z.array(z.unknown()).optional(), + }) + ) + .optional(), + }) + ), +}); + +const citationSchema = z.object({ + type: z.literal("url_citation"), + url: z.url({ protocol: /^https?$/u }), + title: z.string().default(""), +}); + +function requestFailure(signal: AbortSignal) { + if (signal.aborted) { + return new Error( + signal.reason instanceof DOMException && + signal.reason.name === "TimeoutError" + ? "Parallel research timed out after 120 seconds. It may still complete and be billed; do not retry automatically." + : "Parallel research was cancelled. It may still complete and be billed; do not retry automatically." + ); + } + return new Error( + "Parallel research could not finish or returned invalid JSON. The request may have been billed; do not retry automatically." + ); +} + +function researchResult(response: z.infer) { + const parts = response.output + .filter((item) => item.type === "message") + .flatMap((item) => { + if (!item.content) + throw new Error("Parallel returned a message without content."); + return item.content; + }) + .filter((part) => part.type === "output_text"); + if (parts.some((part) => part.text === undefined)) { + throw new Error("Parallel returned research content without text."); + } + // Preserve answer formatting. Citation spans belong to their original text + // parts, so expose source links instead of remapping offsets into joined text. + const answer = parts.map((part) => part.text).join("\n\n"); + if (!answer.trim()) + throw new Error("Parallel returned an empty research answer."); + + const sources = new Map< + string, + Pick, "url" | "title"> + >(); + for (const part of parts) { + for (const annotation of part.annotations ?? []) { + const citation = citationSchema.safeParse(annotation); + if (!citation.success) continue; + const { url, title } = citation.data; + const parsedUrl = new URL(url); + if (parsedUrl.username || parsedUrl.password) continue; + if (!sources.has(url)) sources.set(url, { url, title }); + } + } + const id = responseIdSchema.safeParse(response.id); + const result = { + answer, + sources: [...sources.values()], + }; + if (id.success) return { ...result, response_id: id.data }; + return result; +} + +const researchTool = defineTool({ + description: + "Answer a public-web question using Parallel's paid research API. One call researches sources and returns a synthesized answer with source links, not raw search results. Send the complete question and constraints; no conversation or private account context is sent automatically. Use an explicit previous_response_id only for a follow-up in this conversation. Preserve source links in your answer. Do not automatically retry errors or silently restart failed follow-ups.", + inputSchema, + async execute(input, context) { + // Read credentials at execution time, never into a persisted tool closure. + const apiKey = env.PARALLEL_API_KEY; + if (!apiKey) + throw new Error( + "Parallel research is not configured. Set PARALLEL_API_KEY on the server." + ); + const request = inputSchema.safeParse(input); + if (!request.success) { + throw new Error( + "Invalid research input: provide a nonblank question of at most 20,000 characters, a supported effort, and a nonblank response ID when continuing." + ); + } + const signal = AbortSignal.any([ + context.abortSignal, + AbortSignal.timeout(120_000), + ]); + if (signal.aborted) throw requestFailure(signal); + + let response: Response; + let body: unknown; + try { + response = await fetch("https://api.parallel.ai/v1/responses", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "parallel", + input: request.data.query, + reasoning: { effort: request.data.effort }, + stream: false, + previous_response_id: request.data.previous_response_id, + }), + cache: "no-store", + redirect: "error", + signal, + }); + if (response.ok) body = await response.json(); + } catch { + // Transport and JSON errors can contain request data. Never return them. + throw requestFailure(signal); + } + if (!response.ok) { + void response.body?.cancel().catch(() => undefined); + const hint = + response.status === 401 + ? " Check the server's PARALLEL_API_KEY." + : response.status === 404 && request.data.previous_response_id + ? " The previous response is unavailable; do not silently start fresh." + : ""; + throw new Error( + `Parallel research failed (HTTP ${String(response.status)}).${hint} This call was not retried.` + ); + } + const parsed = responseSchema.safeParse(body); + if (!parsed.success) { + throw new Error( + "Parallel returned an invalid or incomplete research response." + ); + } + return researchResult(parsed.data); + }, +}); + +function availableResearchTool() { + return env.PARALLEL_API_KEY ? researchTool : null; +} + +export default defineDynamic({ + events: { + "session.started": availableResearchTool, + "turn.started": availableResearchTool, + // A key enabled after session creation may have only turn-scoped tools. + // Rebind their executor before a model step resumes in a fresh process. + "step.started": availableResearchTool, + }, +}); diff --git a/src/env.ts b/src/env.ts index 545717b3..9f431660 100644 --- a/src/env.ts +++ b/src/env.ts @@ -81,6 +81,7 @@ export const env = createEnv({ ), // Optional + PARALLEL_API_KEY: requiredValue.optional(), BLOB_READ_WRITE_TOKEN: requiredValue.optional(), BLOB_STORE_ID: requiredValue.optional(), GOOGLE_CONNECTOR_UID: requiredValue.default("google/open-instinct"), diff --git a/src/lib/tests/env.test.ts b/src/lib/tests/env.test.ts index 19859834..b38afc6b 100644 --- a/src/lib/tests/env.test.ts +++ b/src/lib/tests/env.test.ts @@ -17,6 +17,7 @@ describe("environment", () => { vi.stubEnv(name, value); } vi.stubEnv("LINQ_CONNECTOR", ""); + vi.stubEnv("PARALLEL_API_KEY", ""); vi.stubEnv("LINQ_PHONE_NUMBER", ""); }); @@ -58,6 +59,27 @@ describe("environment", () => { expect(localPhoneAuthBypassEnabled).toBe(true); }); + it("keeps Parallel research disabled without an API key", async () => { + const { env } = await import("@/env"); + + expect(env.PARALLEL_API_KEY).toBeUndefined(); + }); + + it("accepts an optional server-side Parallel API key", async () => { + vi.stubEnv("PARALLEL_API_KEY", "test-parallel-key"); + const { env } = await import("@/env"); + + expect(env.PARALLEL_API_KEY).toBe("test-parallel-key"); + }); + + it("rejects whitespace-only Parallel credentials", async () => { + vi.stubEnv("PARALLEL_API_KEY", " "); + + await expect(import("@/env")).rejects.toThrow( + "Invalid environment variables" + ); + }); + it.each([ ["test-auth-secret-0123456789abcdefghijklmnop", ""], ["", Buffer.alloc(32, 2).toString("base64")], diff --git a/tests/agent-tool-boundaries.test.ts b/tests/agent-tool-boundaries.test.ts index 5895574e..7c8540d1 100644 --- a/tests/agent-tool-boundaries.test.ts +++ b/tests/agent-tool-boundaries.test.ts @@ -22,6 +22,7 @@ describe("root and worker capability boundaries", () => { "request_vault_import.ts", "request_vault_setup.ts", "update_user_profile.ts", + "web_research.ts", ]); expect(existsSync(`${rootTools}/sendMessage.ts`)).toBe(false); expect(existsSync("agent/extensions/kernel/extension.ts")).toBe(false); diff --git a/tests/parallel-research.test.ts b/tests/parallel-research.test.ts new file mode 100644 index 00000000..4dbf47f5 --- /dev/null +++ b/tests/parallel-research.test.ts @@ -0,0 +1,596 @@ +import { createServer } from "node:http"; +import type { RequestListener } from "node:http"; +import type { + DynamicResolveContext, + DynamicToolEvents, + ToolContext, +} from "eve/tools"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +const endpoint = "https://api.parallel.ai/v1/responses"; +const apiKey = "test-parallel-key-must-not-leak"; +const nativeFetch = globalThis.fetch; +const fetchMock = vi.fn(); +const requiredEnvironment = { + BETTER_AUTH_SECRET: "test-auth-secret-0123456789abcdefghijklmnop", + BETTER_AUTH_URL: "https://example.com", + DATABASE_URL: "postgresql://user:password@example.com/database", + KERNEL_API_KEY: "test-kernel-key", + SECRET_ENCRYPTION_KEY: "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=", +}; +const resolverContext = { + channel: { + kind: "http", + metadata: { + screenshot: "private-screenshot", + workspace: "private-workspace", + }, + }, + messages: [{ role: "user", content: "private-conversation-history" }], + session: { + auth: { current: null, initiator: null }, + id: "private-session", + }, +} satisfies DynamicResolveContext; + +beforeEach(() => { + vi.resetModules(); + for (const [name, value] of Object.entries(requiredEnvironment)) { + vi.stubEnv(name, value); + } + vi.stubEnv("PARALLEL_API_KEY", apiKey); + vi.stubEnv("LINQ_CONNECTOR", ""); + vi.stubEnv("LINQ_PHONE_NUMBER", ""); + fetchMock.mockReset(); + fetchMock.mockImplementation(() => + Promise.resolve(Response.json(completedResponse())) + ); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe("Parallel research", () => { + it.each([undefined, ""])( + "stays unavailable with an unset key (%s)", + async (key) => { + vi.stubEnv("PARALLEL_API_KEY", key); + expect(await resolveResearch("session.started")).toBeNull(); + expect(await resolveResearch("turn.started")).toBeNull(); + expect(await resolveResearch("step.started")).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + } + ); + + it("rejects a whitespace-only key through the existing environment contract", async () => { + vi.stubEnv("PARALLEL_API_KEY", " \n "); + vi.spyOn(console, "error").mockImplementation(() => undefined); + await expect(resolveResearch()).rejects.toThrow( + "Invalid environment variables" + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each(["low", "medium", "high"])( + "forwards explicit %s effort", + async (effort) => { + await research({ query: "A public question", effort }); + expect(requestBody()).toMatchObject({ reasoning: { effort } }); + expect(fetchMock).toHaveBeenCalledTimes(1); + } + ); + + it.each(["a".repeat(20_000), "🛰".repeat(20_000)])( + "accepts exactly 20,000 Unicode code points without truncation", + async (query) => { + await research({ query }); + expect(requestBody()).toMatchObject({ input: query }); + expect(fetchMock).toHaveBeenCalledTimes(1); + } + ); + + it.each([ + ["missing query", {}], + ["empty query", { query: "" }], + ["whitespace query", { query: " \n\t " }], + ["non-string query", { query: 42 }], + ["oversized ASCII query", { query: "x".repeat(20_001) }], + ["oversized astral query", { query: "🛰".repeat(20_001) }], + ["unknown effort", { query: "public", effort: "extreme" }], + ["null effort", { query: "public", effort: null }], + ["empty prior ID", { query: "public", previous_response_id: "" }], + ["whitespace prior ID", { query: "public", previous_response_id: " \n " }], + [ + "embedded whitespace ID", + { query: "public", previous_response_id: "resp two" }, + ], + ["null prior ID", { query: "public", previous_response_id: null }], + ["numeric prior ID", { query: "public", previous_response_id: 17 }], + [ + "object prior ID", + { query: "public", previous_response_id: { id: "resp_1" } }, + ], + ])("rejects %s at execution before HTTP", async (_name, input) => { + await expect(research(input)).rejects.toThrow(/invalid research input/iu); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("preserves answer formatting and deduplicates safe citation URLs", async () => { + const first = " Findings:\n\n- One. "; + const second = "Second paragraph.\n"; + const response = completedResponse(first, [ + { + type: "url_citation", + url: "https://docs.example.com/a", + title: "", + start_index: 0, + end_index: 0, + }, + { + type: "url_citation", + url: "https://docs.example.com/a", + title: "Duplicate", + start_index: 3, + end_index: 7, + }, + { + type: "url_citation", + url: "http://example.com/b", + title: "Second source", + }, + { type: "url_citation", url: "https://docs.example.com/c" }, + { type: "url_citation", url: "javascript:alert(1)", title: "Unsafe" }, + { type: "url_citation", url: "file:///private/file", title: "Local" }, + { + type: "url_citation", + url: "https://user:secret@example.com/", + title: "Credentials", + }, + { type: "url_citation", url: "not a URL", title: "Invalid" }, + { + type: "file_citation", + url: "https://example.com/not-a-url-citation", + title: "Other type", + }, + null, + ]); + response.output.push({ + type: "message", + role: "assistant", + content: [{ type: "output_text", text: second, annotations: [] }], + }); + response.output.push({ + type: "reasoning", + role: "assistant", + content: [ + { type: "output_text", text: "Not an answer", annotations: [] }, + ], + }); + fetchMock.mockResolvedValueOnce(Response.json(response)); + await expect(research({ query: "A public question" })).resolves.toEqual({ + answer: `${first}\n\n${second}`, + response_id: "resp_current", + sources: [ + { url: "https://docs.example.com/a", title: "" }, + { url: "http://example.com/b", title: "Second source" }, + { url: "https://docs.example.com/c", title: "" }, + ], + }); + }); + + it("accepts a completed answer without annotations or a new response ID", async () => { + fetchMock.mockResolvedValueOnce( + Response.json({ + status: "completed", + output: [ + { + type: "message", + content: [{ type: "output_text", text: "A useful answer." }], + }, + ], + }) + ); + await expect( + research({ query: "Follow up", previous_response_id: "resp_previous" }) + ).resolves.toEqual({ answer: "A useful answer.", sources: [] }); + }); + + it.each([null, 0, "", " \n ", "id with spaces", { id: "nested" }])( + "omits a malformed returned ID (%j) without reusing the prior ID", + async (id) => { + fetchMock.mockResolvedValueOnce( + Response.json({ ...completedResponse(), id }) + ); + await expect( + research({ query: "Follow up", previous_response_id: "resp_previous" }) + ).resolves.toEqual({ + answer: "The official documentation is available online.", + sources: [], + }); + } + ); + + it.each(["opaque:branch/a?b=1", `opaque:${"a".repeat(600)}`])( + "treats response IDs as opaque rather than imposing a prefix or length cap", + async (id) => { + fetchMock.mockResolvedValueOnce( + Response.json({ ...completedResponse(), id }) + ); + await expect( + research({ query: "Follow up", previous_response_id: id }) + ).resolves.toMatchObject({ response_id: id }); + expect(requestBody()).toMatchObject({ previous_response_id: id }); + } + ); + + it("uses explicit continuation only and leaves later questions independent", async () => { + const first = await research({ query: "Initial question" }); + if (!("response_id" in first)) throw new Error("Missing follow-up ID."); + await research({ + query: "Follow up", + previous_response_id: first.response_id, + }); + await research({ query: "Independent question" }); + expect(requestBody(0)).not.toHaveProperty("previous_response_id"); + expect(requestBody(1)).toMatchObject({ + previous_response_id: "resp_current", + }); + expect(requestBody(2)).not.toHaveProperty("previous_response_id"); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("keeps concurrent independent requests separate", async () => { + const pending = Promise.withResolvers(); + fetchMock.mockImplementationOnce(() => pending.promise); + fetchMock.mockResolvedValueOnce( + Response.json({ + ...completedResponse("Second answer"), + id: "resp_second", + }) + ); + const first = research({ query: "First question" }); + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + await expect(research({ query: "Second question" })).resolves.toMatchObject( + { answer: "Second answer", response_id: "resp_second" } + ); + pending.resolve( + Response.json({ ...completedResponse("First answer"), id: "resp_first" }) + ); + await expect(first).resolves.toMatchObject({ + answer: "First answer", + response_id: "resp_first", + }); + expect(requestBody(0)).toMatchObject({ input: "First question" }); + expect(requestBody(1)).toMatchObject({ input: "Second question" }); + expect(requestBody(0)).not.toHaveProperty("previous_response_id"); + expect(requestBody(1)).not.toHaveProperty("previous_response_id"); + }); + + it.each([400, 401, 403, 404, 429, 500])( + "reports HTTP %s without leaking the body or retrying a follow-up", + async (status) => { + const query = "private-query-must-not-leak"; + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { message: `raw-provider-body ${apiKey} ${query}` }, + }), + { status } + ) + ); + const error = await research({ + query, + previous_response_id: "opaque-unauthorized", + }).catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(Error); + expect(String(error)).toContain(`HTTP ${String(status)}`); + expect(String(error)).not.toContain(apiKey); + expect(String(error)).not.toContain(query); + expect(String(error)).not.toContain("raw-provider-body"); + expect( + String(error).includes("Check the server's PARALLEL_API_KEY") + ).toBe(status === 401); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(requestBody()).toMatchObject({ + previous_response_id: "opaque-unauthorized", + }); + } + ); + + it.each([ + ["null payload", null], + ["missing status", { output: completedResponse().output }], + ["failed status", { ...completedResponse(), status: "failed" }], + ["incomplete status", { ...completedResponse(), status: "incomplete" }], + ["empty output", { status: "completed", output: [] }], + ["missing content", { status: "completed", output: [{ type: "message" }] }], + [ + "missing text", + { + status: "completed", + output: [{ type: "message", content: [{ type: "output_text" }] }], + }, + ], + [ + "non-string text", + { + status: "completed", + output: [ + { type: "message", content: [{ type: "output_text", text: 5 }] }, + ], + }, + ], + ["whitespace answer", completedResponse(" \n\t ")], + [ + "non-message answer", + { + status: "completed", + output: [ + { + type: "reasoning", + content: [{ type: "output_text", text: "Not an answer" }], + }, + ], + }, + ], + ])("rejects %s instead of reporting success", async (_name, value) => { + fetchMock.mockResolvedValueOnce(Response.json(value)); + await expect(research({ query: "Public question" })).rejects.toThrow( + /Parallel/u + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("sanitizes malformed JSON and transport failures", async () => { + fetchMock.mockResolvedValueOnce(new Response(`malformed ${apiKey}`)); + const sanitized = new Error( + "Parallel research could not finish or returned invalid JSON. The request may have been billed; do not retry automatically." + ); + await expect(research({ query: "Public question" })).rejects.toEqual( + sanitized + ); + fetchMock.mockRejectedValueOnce(new TypeError(`request failed: ${apiKey}`)); + await expect(research({ query: "Public question" })).rejects.toEqual( + sanitized + ); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("does not start HTTP for a pre-cancelled call", async () => { + const controller = new AbortController(); + controller.abort(new Error(`private abort reason ${apiKey}`)); + await expect( + research({ query: "Public question" }, controller.signal) + ).rejects.toThrow(/cancelled/iu); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([ + ["headers", "cancel"], + ["body", "cancel"], + ["headers", "timeout"], + ["body", "timeout"], + ] as const)( + "aborts native fetch waiting for %s on %s", + async (phase, cause) => { + const arrived = Promise.withResolvers(); + const closed = Promise.withResolvers(); + const caller = new AbortController(); + const deadline = new AbortController(); + const timeout = vi + .spyOn(AbortSignal, "timeout") + .mockReturnValue(deadline.signal); + await withResponseServer( + (_request, response) => { + response.once("close", () => { + closed.resolve(undefined); + }); + if (phase === "body") { + response.writeHead(200, { "Content-Type": "application/json" }); + response.write('{"status":"completed","output":'); + } + arrived.resolve(undefined); + }, + async () => { + const result = research({ query: "Public question" }, caller.signal); + await arrived.promise; + if (phase === "body") await fetchMock.mock.results[0]?.value; + if (cause === "timeout") { + deadline.abort(new DOMException("Deadline", "TimeoutError")); + } else { + caller.abort(new Error(`private reason ${apiKey}`)); + } + await expect(result).rejects.toThrow( + cause === "timeout" ? /timed out/iu : /cancelled/iu + ); + await closed.promise; + expect(timeout).toHaveBeenCalledExactlyOnceWith(120_000); + expect(fetchMock).toHaveBeenCalledTimes(1); + } + ); + } + ); + + it("rejects a real redirect without sending the credential to its target", async () => { + const paths: string[] = []; + await withResponseServer( + (request, response) => { + paths.push(request.url ?? ""); + response.writeHead(302, { Location: "/do-not-follow" }); + response.end(); + }, + async () => { + await expect(research({ query: "Public question" })).rejects.toThrow( + /could not finish/iu + ); + expect(paths).toEqual(["/v1/responses"]); + expect(fetchMock).toHaveBeenCalledTimes(1); + } + ); + }); + + it.each(["session.started", "turn.started", "step.started"] as const)( + "resolves an optional tool on %s without making a request", + async (event) => { + const tool = await resolveResearch(event); + expect(tool?.execute).toBeTypeOf("function"); + if (!tool || !(tool.inputSchema instanceof z.ZodType)) { + throw new Error("Research must expose a Zod input schema."); + } + expect(tool.inputSchema.parse({ query: "A public question" })).toEqual({ + query: "A public question", + effort: "low", + }); + expect(fetchMock).not.toHaveBeenCalled(); + } + ); + + it("sends only the exact question and protocol fields, never ambient or extra input", async () => { + const query = + " Find the official Bun documentation.\nUse public sources. "; + await expect( + research({ + query, + instructions: "must-not-forward", + screenshots: ["private-image"], + messages: resolverContext.messages, + tools: [{ name: "private-tool" }], + api_key: "untrusted-override", + store: false, + }) + ).resolves.toEqual({ + answer: "The official documentation is available online.", + response_id: "resp_current", + sources: [], + }); + + expect(fetchMock).toHaveBeenCalledExactlyOnceWith( + endpoint, + expect.objectContaining({ + method: "POST", + cache: "no-store", + redirect: "error", + }) + ); + const options = fetchMock.mock.calls[0]?.[1]; + expect(options?.signal).toBeInstanceOf(AbortSignal); + expect(new Headers(options?.headers).get("authorization")).toBe( + `Bearer ${apiKey}` + ); + expect(new Headers(options?.headers).get("content-type")).toBe( + "application/json" + ); + expect(requestBody()).toEqual({ + input: query, + model: "parallel", + reasoning: { effort: "low" }, + stream: false, + }); + }); +}); + +async function resolveResearch( + event: keyof DynamicToolEvents = "session.started" +) { + const { default: definition } = await import("../agent/tools/web_research"); + const resolve = definition.events[event]; + if (!resolve) { + throw new Error(`Missing research resolver for ${event}.`); + } + return resolve({ type: event }, resolverContext); +} + +async function research( + input: z.util.JSONType, + signal = new AbortController().signal +) { + const tool = await resolveResearch(); + if (!tool) { + throw new Error("Research tool is unavailable."); + } + // SAFETY: The real executor validates this raw JSON, including deliberately invalid test inputs, before HTTP. + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- exercise validation of raw runtime input in the real executor, including deliberately invalid cases. + const raw = input as Parameters[0]; + // SAFETY: The proxy supplies abortSignal and throws on every other access to verify ambient context is never read. + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the real executor may access only abortSignal; ambient Eve context is deliberately unavailable. + const context = new Proxy( + { abortSignal: signal }, + { + get(target, property) { + if (property === "abortSignal") return target.abortSignal; + throw new Error( + `Unexpected research context access: ${String(property)}` + ); + }, + } + ) as ToolContext; + const result = await tool.execute(raw, context); + if (Symbol.asyncIterator in result) { + throw new Error("Research must return one completed result."); + } + return result; +} + +function requestBody(index = 0) { + const body = z.string().parse(fetchMock.mock.calls[index]?.[1]?.body); + return z.json().parse(JSON.parse(body)); +} + +function completedResponse( + text = "The official documentation is available online.", + annotations: unknown[] = [] +) { + return { + id: "resp_current", + output: [ + { + content: [ + { + annotations, + text, + type: "output_text", + }, + ], + role: "assistant", + type: "message", + }, + ], + status: "completed", + }; +} + +async function withResponseServer( + onRequest: RequestListener, + run: () => Promise +) { + const server = createServer(onRequest); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = z.object({ port: z.number() }).parse(server.address()); + fetchMock.mockImplementation((url, options) => { + expect(url).toBe(endpoint); + return nativeFetch( + `http://127.0.0.1:${String(address.port)}/v1/responses`, + options + ); + }); + try { + await run(); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => { + server.close(() => { + resolve(); + }); + }); + } +} diff --git a/tests/turbo-config.test.ts b/tests/turbo-config.test.ts index 4a758395..6836465e 100644 --- a/tests/turbo-config.test.ts +++ b/tests/turbo-config.test.ts @@ -10,6 +10,7 @@ const applicationEnvironment = [ "KERNEL_*", "LINQ_*", "NODE_ENV", + "PARALLEL_API_KEY", "SECRET_ENCRYPTION_KEY", "VERCEL_*", ]; diff --git a/turbo.json b/turbo.json index 129fb85f..f55ba6a4 100644 --- a/turbo.json +++ b/turbo.json @@ -12,6 +12,7 @@ "KERNEL_*", "LINQ_*", "NODE_ENV", + "PARALLEL_API_KEY", "SECRET_ENCRYPTION_KEY", "VERCEL_*" ], @@ -27,6 +28,7 @@ "KERNEL_*", "LINQ_*", "NODE_ENV", + "PARALLEL_API_KEY", "SECRET_ENCRYPTION_KEY", "VERCEL_*" ], @@ -47,6 +49,7 @@ "KERNEL_*", "LINQ_*", "NODE_ENV", + "PARALLEL_API_KEY", "SECRET_ENCRYPTION_KEY", "VERCEL_*" ], @@ -74,6 +77,7 @@ "KERNEL_*", "LINQ_*", "NODE_ENV", + "PARALLEL_API_KEY", "SECRET_ENCRYPTION_KEY", "VERCEL_*" ],