diff --git a/.github/workflows/foundry-build.yml b/.github/workflows/foundry-build.yml new file mode 100644 index 000000000..48bc18f24 --- /dev/null +++ b/.github/workflows/foundry-build.yml @@ -0,0 +1,30 @@ +name: Foundry Pi package + +on: + pull_request: + push: + branches: [master] + +permissions: + contents: read + +jobs: + build: + name: build + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + # Exercise exactly the Git-subdirectory package Foundry installs. A + # broad monorepo build alone can miss a broken Pi extension entrypoint. + - run: npm install --prefix packages/pi --ignore-scripts --no-audit --no-fund + - run: npm run --prefix packages/pi typecheck + - run: npm run --prefix packages/pi test + - name: Inspect packed Pi resources + working-directory: packages/pi + run: | + npm pack --dry-run --json > /tmp/context7-pi-pack.json + node -e 'const p=require("/tmp/context7-pi-pack.json")[0]; for (const file of ["extensions/context7.ts", "lib/api.ts", "LICENSE"]) if (!p.files.some(f=>f.path===file)) process.exitCode=1' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b361abea..9a9262f65 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,8 @@ concurrency: ${{ github.workflow }}-${{ github.ref }} jobs: release: name: Release + # This public fork must never publish to upstream npm/GitHub registries. + if: github.repository == 'upstash/context7' runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 461d40cae..f53a8bfba 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,7 +69,7 @@ jobs: run: pnpm typecheck - name: Configure AWS credentials - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + if: github.repository == 'upstash/context7' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: ${{ secrets.AWS_BEDROCK_ROLE_ARN }} @@ -79,12 +79,20 @@ jobs: role-session-name: context7-bedrock-${{ github.run_id }} - name: Test - run: pnpm test + # This fork has no upstream Bedrock credentials: the tools-ai-sdk + # suite makes live AWS calls. Still lint/build/typecheck the full + # monorepo, and run the complete Pi suite that Foundry consumes. + run: | + if [ "$GITHUB_REPOSITORY" = "upstash/context7" ]; then + pnpm test + else + pnpm --filter @upstash/context7-pi test + fi env: CONTEXT7_API_KEY: ${{ secrets.CONTEXT7_API_KEY }} - name: SDK Integration Test - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + if: github.repository == 'upstash/context7' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) run: pnpm --filter @upstash/context7-sdk test:integration env: CONTEXT7_API_KEY: ${{ secrets.CONTEXT7_API_KEY }} diff --git a/packages/pi/__tests__/api-bounds.test.ts b/packages/pi/__tests__/api-bounds.test.ts new file mode 100644 index 000000000..5b482bb8b --- /dev/null +++ b/packages/pi/__tests__/api-bounds.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fetchLibraryContext, searchLibraries } from "../lib/api"; + +// Observe the actual fetch request, not a model's description of a tool call. +afterEach(() => vi.unstubAllGlobals()); + +describe("bounded Context7 transport", () => { + it("uses only Context7's fixed endpoint and honors the caller's abort", async () => { + const fetchMock = vi.fn( + async (_url: URL, _options: RequestInit) => + new Response(JSON.stringify({ results: [{ id: "/facebook/react" }] }), { status: 200 }) + ); + vi.stubGlobal("fetch", fetchMock); + const abort = new AbortController(); + expect((await searchLibraries("React hooks", "React", abort.signal)).results).toHaveLength(1); + const [url, options] = fetchMock.mock.calls[0] as [URL, RequestInit]; + expect(url.origin).toBe("https://context7.com"); + expect(url.pathname).toBe("/api/v2/libs/search"); + expect(url.searchParams.get("query")).toBe("React hooks"); + expect(options.signal?.aborted).toBe(false); + abort.abort(); + expect(options.signal?.aborted).toBe(true); + }); + + it("rejects long or control-character queries before transmitting any bytes", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + await expect(fetchLibraryContext("private\nfile", "/facebook/react")).rejects.toThrow( + /Invalid Context7 query/ + ); + await expect(searchLibraries("x".repeat(801), "React")).rejects.toThrow( + /Invalid Context7 query/ + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("refuses an oversized documentation response", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("x".repeat(128 * 1024 + 1), { status: 200 })) + ); + await expect(fetchLibraryContext("What is useEffect?", "/facebook/react")).rejects.toThrow( + /response exceeded/ + ); + }); + + it("cancels an in-flight query when the caller aborts", async () => { + const fetchMock = vi.fn( + (_url: URL, options: RequestInit) => + new Promise((_resolve, reject) => { + options.signal?.addEventListener( + "abort", + () => reject(new DOMException("cancelled", "AbortError")), + { once: true } + ); + }) + ); + vi.stubGlobal("fetch", fetchMock); + const abort = new AbortController(); + const pending = fetchLibraryContext("What is useEffect?", "/facebook/react", abort.signal); + abort.abort(); + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("does not mistake cancellation of an HTTP error body for a completed tool result", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (_url: URL, options: RequestInit) => { + const body = new ReadableStream({ + start(controller) { + options.signal?.addEventListener( + "abort", + () => controller.error(new DOMException("cancelled", "AbortError")), + { once: true } + ); + }, + }); + return new Response(body, { status: 503 }); + }) + ); + const abort = new AbortController(); + const pending = searchLibraries("React hooks", "React", abort.signal); + abort.abort(); + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + }); + + it("returns bounded error text without treating HTTP errors as healthy results", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ message: "not ready" }), { status: 503 })) + ); + expect(await searchLibraries("React hooks", "React")).toMatchObject({ + results: [], + error: "not ready", + }); + }); +}); diff --git a/packages/pi/lib/api.ts b/packages/pi/lib/api.ts index 3dd351fca..eb3f9eae6 100644 --- a/packages/pi/lib/api.ts +++ b/packages/pi/lib/api.ts @@ -6,18 +6,67 @@ import type { SearchResponse } from "./types"; const BASE_URL = "https://context7.com/api"; +// Foundry's trusted Pi process must not wait indefinitely or consume an +// unbounded response from a remote documentation service. Agent-selected query +// text is bounded here; the seeded Foundry health probe uses only fixed public +// documentation strings (normal Pi usage remains the developer's choice). +const REQUEST_TIMEOUT_MS = 12_000; +const MAX_QUERY_CHARS = 800; +const MAX_RESULT_BYTES = 128 * 1024; function authHeaders(): Record { const apiKey = process.env.CONTEXT7_API_KEY; return apiKey ? { Authorization: `Bearer ${apiKey}` } : {}; } +function checkedInput(value: string, name: string): string { + if (!value.trim() || value.length > MAX_QUERY_CHARS || /[\x00-\x1f\x7f]/.test(value)) { + throw new Error( + `Invalid Context7 ${name}: nonempty public-doc text of at most ${MAX_QUERY_CHARS} characters is required` + ); + } + return value; +} + +function requestSignal(signal?: AbortSignal): AbortSignal { + const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} + +async function boundedText(response: Response): Promise { + if (!response.body) return ""; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let bytes = 0; + let result = ""; + let completed = false; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) { + completed = true; + break; + } + bytes += value.byteLength; + if (bytes > MAX_RESULT_BYTES) throw new Error("Context7 response exceeded the 128 KiB limit"); + result += decoder.decode(value, { stream: true }); + } + return result + decoder.decode(); + } finally { + if (!completed) await reader.cancel().catch(() => undefined); + reader.releaseLock(); + } +} + async function parseErrorResponse(response: Response): Promise { + // A failed/aborted body read is not a usable HTTP error message. Only a + // malformed JSON payload may fall back to the status-based diagnostic. + const text = await boundedText(response); try { - const json = (await response.json()) as { message?: string }; - if (json.message) return json.message; + const json = JSON.parse(text) as { message?: unknown }; + if (typeof json.message === "string") return json.message.slice(0, 300); } catch { - // JSON parsing failed, fall through to status-based message + // The status below remains useful if the error body is not JSON. } const hasKey = Boolean(process.env.CONTEXT7_API_KEY); @@ -35,29 +84,37 @@ async function parseErrorResponse(response: Response): Promise { return `Request failed with status ${response.status}. Please try again later.`; } -export async function searchLibraries(query: string, libraryName: string): Promise { +export async function searchLibraries( + query: string, + libraryName: string, + signal?: AbortSignal +): Promise { const url = new URL(`${BASE_URL}/v2/libs/search`); - url.searchParams.set("query", query); - url.searchParams.set("libraryName", libraryName); + url.searchParams.set("query", checkedInput(query, "query")); + url.searchParams.set("libraryName", checkedInput(libraryName, "library name")); - const response = await fetch(url, { headers: authHeaders() }); + const response = await fetch(url, { headers: authHeaders(), signal: requestSignal(signal) }); if (!response.ok) { return { results: [], error: await parseErrorResponse(response) }; } - return (await response.json()) as SearchResponse; + return JSON.parse(await boundedText(response)) as SearchResponse; } -export async function fetchLibraryContext(query: string, libraryId: string): Promise { +export async function fetchLibraryContext( + query: string, + libraryId: string, + signal?: AbortSignal +): Promise { const url = new URL(`${BASE_URL}/v2/context`); - url.searchParams.set("query", query); - url.searchParams.set("libraryId", libraryId); + url.searchParams.set("query", checkedInput(query, "query")); + url.searchParams.set("libraryId", checkedInput(libraryId, "library ID")); - const response = await fetch(url, { headers: authHeaders() }); + const response = await fetch(url, { headers: authHeaders(), signal: requestSignal(signal) }); if (!response.ok) { return parseErrorResponse(response); } - const text = await response.text(); + const text = await boundedText(response); if (!text) { return "Documentation not found or not finalized for this library. This might have happened because you used an invalid Context7-compatible library ID. To get a valid Context7-compatible library ID, use the 'resolve-library-id' with the package name you wish to retrieve documentation for."; } diff --git a/packages/pi/lib/tools/query-docs.ts b/packages/pi/lib/tools/query-docs.ts index 28338f0da..cc99526f3 100644 --- a/packages/pi/lib/tools/query-docs.ts +++ b/packages/pi/lib/tools/query-docs.ts @@ -19,8 +19,8 @@ export const queryDocsTool: ToolDefinition = { label: QUERY_DOCS_TITLE, description: QUERY_DOCS_DESCRIPTION, parameters: Params, - async execute(_toolCallId: string, params: Static) { - const text = await fetchLibraryContext(params.query, params.libraryId); + async execute(_toolCallId: string, params: Static, signal?: AbortSignal) { + const text = await fetchLibraryContext(params.query, params.libraryId, signal); return toToolResult(text); }, }; diff --git a/packages/pi/lib/tools/resolve-library-id.ts b/packages/pi/lib/tools/resolve-library-id.ts index 637b37ce9..01e3e4d04 100644 --- a/packages/pi/lib/tools/resolve-library-id.ts +++ b/packages/pi/lib/tools/resolve-library-id.ts @@ -20,8 +20,8 @@ export const resolveLibraryIdTool: ToolDefinition = { label: RESOLVE_LIBRARY_ID_TITLE, description: RESOLVE_LIBRARY_ID_DESCRIPTION, parameters: Params, - async execute(_toolCallId: string, params: Static) { - const searchResponse = await searchLibraries(params.query, params.libraryName); + async execute(_toolCallId: string, params: Static, signal?: AbortSignal) { + const searchResponse = await searchLibraries(params.query, params.libraryName, signal); if (!searchResponse.results || searchResponse.results.length === 0) { return toToolResult(searchResponse.error ?? "No libraries found matching the provided name."); }