diff --git a/src/core/index.tsx b/src/core/index.tsx index 5a6bab9fb..9ae2f021a 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -76,6 +76,7 @@ export class CoreClient implements AwsClients { readonly projectManager: ProjectManager; readonly describeBedrockAgent: DescribeBedrockAgent; + readonly fetch: CoreFetch; constructor(config: CoreClientConfig) { this.createControlClient = config.createControlClient; @@ -84,6 +85,7 @@ export class CoreClient implements AwsClients { this.createLogsClient = config.createLogsClient; this.logger = config.logger; const fetch = config.fetch ?? globalThis.fetch; + this.fetch = fetch; this.runtime = new RuntimeClient(this, fetch, this.logger.child({ module: "runtime" })); this.gateway = new GatewayClient(this, fetch, this.logger.child({ module: "gateway" })); // EvalClient shares the injected fetch: dataset content is served from a diff --git a/src/handlers/feedback/__fixtures__/shot.png b/src/handlers/feedback/__fixtures__/shot.png new file mode 100644 index 000000000..875245de1 Binary files /dev/null and b/src/handlers/feedback/__fixtures__/shot.png differ diff --git a/src/handlers/feedback/__fixtures__/submit-screenshot.golden.json b/src/handlers/feedback/__fixtures__/submit-screenshot.golden.json new file mode 100644 index 000000000..a72012723 --- /dev/null +++ b/src/handlers/feedback/__fixtures__/submit-screenshot.golden.json @@ -0,0 +1,6 @@ +{ + "success": true, + "id": "9385c2bc-e013-4c31-a5c4-e430f221037a", + "timestamp": "2026-08-31T20:04:07.165808755Z", + "reference": "agentcore-cli" +} \ No newline at end of file diff --git a/src/handlers/feedback/__fixtures__/submit-screenshot/Fetch.499b0768d488bbcf.json b/src/handlers/feedback/__fixtures__/submit-screenshot/Fetch.499b0768d488bbcf.json new file mode 100644 index 000000000..0e218e8c7 --- /dev/null +++ b/src/handlers/feedback/__fixtures__/submit-screenshot/Fetch.499b0768d488bbcf.json @@ -0,0 +1,5 @@ +{ + "status": 200, + "statusText": "OK", + "body": "" +} \ No newline at end of file diff --git a/src/handlers/feedback/__fixtures__/submit-screenshot/Fetch.92c60dabfb9be7f2.json b/src/handlers/feedback/__fixtures__/submit-screenshot/Fetch.92c60dabfb9be7f2.json new file mode 100644 index 000000000..676884db3 --- /dev/null +++ b/src/handlers/feedback/__fixtures__/submit-screenshot/Fetch.92c60dabfb9be7f2.json @@ -0,0 +1,5 @@ +{ + "status": 200, + "statusText": "OK", + "body": "{\"reference\":\"agentcore-cli\",\"id\":\"9385c2bc-e013-4c31-a5c4-e430f221037a\",\"timestamp\":\"2026-08-31T20:04:07.165808755Z\"}" +} \ No newline at end of file diff --git a/src/handlers/feedback/__fixtures__/submit-screenshot/Fetch.f7c65f1fef88f718.json b/src/handlers/feedback/__fixtures__/submit-screenshot/Fetch.f7c65f1fef88f718.json new file mode 100644 index 000000000..7c0298d14 --- /dev/null +++ b/src/handlers/feedback/__fixtures__/submit-screenshot/Fetch.f7c65f1fef88f718.json @@ -0,0 +1,5 @@ +{ + "status": 200, + "statusText": "OK", + "body": "https://aperture-forms-uploaded-files-prod-us-east-1.s3.amazonaws.com/us-east-1/AgentCore/CLI/0.1.0/31082026/3066c98d-4cda-42d5-ad1e-b00e8f69b96f.png" +} diff --git a/src/handlers/feedback/__fixtures__/submit-text.golden.json b/src/handlers/feedback/__fixtures__/submit-text.golden.json new file mode 100644 index 000000000..ecd3e34af --- /dev/null +++ b/src/handlers/feedback/__fixtures__/submit-text.golden.json @@ -0,0 +1,6 @@ +{ + "success": true, + "id": "395e1470-9d77-40a8-af81-9b1181bca976", + "timestamp": "2026-08-31T20:03:36.685002013Z", + "reference": "agentcore-cli" +} \ No newline at end of file diff --git a/src/handlers/feedback/__fixtures__/submit-text/Fetch.92c60dabfb9be7f2.json b/src/handlers/feedback/__fixtures__/submit-text/Fetch.92c60dabfb9be7f2.json new file mode 100644 index 000000000..f82879966 --- /dev/null +++ b/src/handlers/feedback/__fixtures__/submit-text/Fetch.92c60dabfb9be7f2.json @@ -0,0 +1,5 @@ +{ + "status": 200, + "statusText": "OK", + "body": "{\"reference\":\"agentcore-cli\",\"id\":\"395e1470-9d77-40a8-af81-9b1181bca976\",\"timestamp\":\"2026-08-31T20:03:36.685002013Z\"}" +} \ No newline at end of file diff --git a/src/handlers/feedback/feedback.fixture.test.tsx b/src/handlers/feedback/feedback.fixture.test.tsx new file mode 100644 index 000000000..d0657a22d --- /dev/null +++ b/src/handlers/feedback/feedback.fixture.test.tsx @@ -0,0 +1,178 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { CoreClient } from "../../core"; +import { createRootHandler } from "../index"; +import { + createSilentLogger, + fixtureFactories, + fixtureFetch, + matchGolden, + TestGlobalConfigAccessor, + testIO, + type TestIOOptions, +} from "../../testing"; +import { UserCancellationError } from "../../errors"; +import { ApertureError } from "./submit"; +import type { CoreFetch } from "../../core/types"; + +const REGION = "us-east-1"; +const FIXTURES = join(import.meta.dir, "__fixtures__"); +const SHOT = join(FIXTURES, "shot.png"); + +const neverFetch = (async () => { + throw new Error("network should not be reached"); +}) as unknown as CoreFetch; + +async function run( + args: string[], + opts: { fetch?: CoreFetch; io?: TestIOOptions } = {}, +): Promise<{ stdout: string; stderr: string }> { + const io = testIO(opts.io); + const core = new CoreClient({ + ...fixtureFactories(FIXTURES), + logger: createSilentLogger(), + fetch: opts.fetch ?? neverFetch, + }); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + await root.route(["node", "agentcore", "feedback", ...args, "--region", REGION]); + return { stdout: io.stdout(), stderr: io.stderr() }; +} + +describe("feedback (fixture-backed)", () => { + test("submits text-only feedback and prints the result envelope", async () => { + const { stdout } = await run( + ["[agentcore-cli golden fixture] text submit — please ignore", "--yes", "--json"], + { fetch: fixtureFetch(join(FIXTURES, "submit-text")) }, + ); + + matchGolden(FIXTURES, "submit-text.golden.json", stdout); + const result = JSON.parse(stdout); + expect(result.success).toBe(true); + expect(typeof result.id).toBe("string"); + expect(result.reference).toBe("agentcore-cli"); + }, 120_000); + + test("submits feedback with a screenshot (presign → S3 PUT → form)", async () => { + const { stdout } = await run( + [ + "[agentcore-cli golden fixture] screenshot submit — please ignore", + "--screenshot", + SHOT, + "--yes", + "--json", + ], + { fetch: fixtureFetch(join(FIXTURES, "submit-screenshot")) }, + ); + + matchGolden(FIXTURES, "submit-screenshot.golden.json", stdout); + expect(JSON.parse(stdout).success).toBe(true); + }, 120_000); + + test("without --yes and without a TTY it fails rather than submitting", async () => { + await expect(run(["headless", "--json"], { fetch: neverFetch })).rejects.toThrow(/--yes/); + }); + + test("declining the consent prompt cancels", async () => { + await expect( + run(["no thanks"], { fetch: neverFetch, io: { isTTY: true, stdin: "n\n" } }), + ).rejects.toBeInstanceOf(UserCancellationError); + }); + + test("an empty message is rejected", async () => { + await expect(run([" ", "--yes"], { fetch: neverFetch })).rejects.toThrow(/cannot be empty/); + }); + + test("a message over 1000 characters is rejected", async () => { + await expect(run(["x".repeat(1001), "--yes"], { fetch: neverFetch })).rejects.toThrow( + /1000 characters/, + ); + }); + + test("an explicitly-empty --screenshot is rejected", async () => { + await expect(run(["msg", "--screenshot", "", "--yes"], { fetch: neverFetch })).rejects.toThrow( + /--screenshot requires a file path/, + ); + }); +}); + +type Recorded = { url: string; method: string; headers: Headers; body: unknown }; + +function capturingFetch(canned: { presign?: string; form?: string; formStatus?: number }): { + fetch: CoreFetch; + calls: Recorded[]; +} { + const calls: Recorded[] = []; + const fetch = (async (input: Parameters[0], init?: Parameters[1]) => { + const url = String(input); + calls.push({ + url, + method: init?.method ?? "GET", + headers: new Headers(init?.headers), + body: init?.body, + }); + if (url.includes("/presignedurl")) { + return new Response(canned.presign ?? "", { status: 200 }); + } + if (url.includes("/form")) { + return new Response(canned.form ?? "{}", { + status: canned.formStatus ?? 200, + headers: { "content-type": "application/json" }, + }); + } + return new Response(null, { status: 200 }); + }) as unknown as CoreFetch; + return { fetch, calls }; +} + +const OK_FORM = JSON.stringify({ + reference: "agentcore-cli", + id: "id-1", + timestamp: "2026-01-01T00:00:00Z", +}); +const PRESIGN_URL = + "https://bucket.s3.us-east-1.amazonaws.com/us-east-1/AgentCore/CLI/0.1.0/13052026/abc-123.png?X-Amz-Signature=sig"; + +describe("feedback (request contract)", () => { + test("screenshot flow sends checksum + NOT_SCANNED headers and the parsed object key", async () => { + const { fetch, calls } = capturingFetch({ presign: PRESIGN_URL, form: OK_FORM }); + await run(["with shot", "--screenshot", SHOT, "--yes", "--json"], { fetch }); + + expect( + calls.map((c) => + c.url.includes("/presignedurl") ? "presign" : c.url.includes("/form") ? "form" : "s3", + ), + ).toEqual(["presign", "s3", "form"]); + + const put = calls[1]!; + expect(put.method).toBe("PUT"); + expect(put.headers.get("x-amz-checksum-algorithm")).toBe("SHA256"); + expect(put.headers.get("x-amz-checksum-sha256")).toBeTruthy(); + expect(put.headers.get("x-amz-tagging")).toBe("scanstatus=NOT_SCANNED"); + + const form = JSON.parse(String(calls[2]!.body)); + const attachment = form.customerResponses.find( + (r: { response: { responseType: string } }) => r.response.responseType === "fileUpload", + ); + expect(attachment.response.responseValue).toEqual([ + "us-east-1/AgentCore/CLI/0.1.0/13052026/abc-123.png", + ]); + }); + + test("a malformed form response is rejected as an ApertureError", async () => { + const { fetch } = capturingFetch({ form: "{}" }); + await expect(run(["hi", "--yes", "--json"], { fetch })).rejects.toBeInstanceOf(ApertureError); + }); + + test("an invalid presign body fails before the upload (no PUT)", async () => { + const { fetch, calls } = capturingFetch({ presign: "not-a-url", form: OK_FORM }); + await expect( + run(["with shot", "--screenshot", SHOT, "--yes", "--json"], { fetch }), + ).rejects.toBeInstanceOf(ApertureError); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toContain("/presignedurl"); + }); +}); diff --git a/src/handlers/feedback/index.tsx b/src/handlers/feedback/index.tsx new file mode 100644 index 000000000..ad3bc5e95 --- /dev/null +++ b/src/handlers/feedback/index.tsx @@ -0,0 +1,77 @@ +import { createInterface } from "node:readline/promises"; +import z from "zod"; +import { argument, createHandler, flag } from "../../router"; +import { JsonRendererKey } from "../../tui"; +import { JsonKey } from "../keys.tsx"; +import { InputValidationError, UserCancellationError } from "../../errors"; +import { CONSENT_TEXT, submitFeedback } from "./submit"; +import type { AppIO } from "../../io"; +import type { Core } from "../types.tsx"; + +export const createFeedbackHandler = (core: Core, io: AppIO) => + createHandler({ + name: "feedback", + description: "Send feedback about the AgentCore CLI to the team.", + arguments: [argument("message", "the feedback message to send", z.string())], + flags: [ + flag( + "screenshot", + "path to a PNG or JPG screenshot to attach (max 100MB)", + z.string().optional(), + ), + flag( + "yes", + "accept the AWS Customer Agreement and skip the consent prompt", + z.boolean().default(false), + ), + ], + handle: async (ctx, flags, args) => { + const screenshotPath = flags["screenshot"]; + if (screenshotPath !== undefined && screenshotPath.trim() === "") { + throw new InputValidationError("--screenshot requires a file path"); + } + + await confirmConsent(io, ctx.require(JsonKey), flags.yes); + + const result = await submitFeedback( + { + message: args["message"], + screenshot: screenshotPath ? { path: screenshotPath } : undefined, + }, + core.fetch, + ); + + ctx.require(JsonRendererKey).renderJson({ success: true, ...result }); + }, + }); + +async function confirmConsent(io: AppIO, jsonOutput: boolean, confirmed: boolean): Promise { + if (confirmed) return; + const canPrompt = !jsonOutput && io.stdin.isTTY && io.stdout.isTTY && io.stderr.isTTY; + if (!canPrompt) { + throw new InputValidationError( + "submitting feedback requires accepting the AWS Customer Agreement; re-run with --yes to confirm non-interactively", + ); + } + if (!(await promptForConsent(io))) { + throw new UserCancellationError(); + } +} + +async function promptForConsent(io: AppIO): Promise { + const readline = createInterface({ input: io.stdin, output: io.stderr }); + try { + const cancelled = new Promise((_resolve, reject) => { + const cancel = () => reject(new UserCancellationError()); + readline.once("SIGINT", cancel); + readline.once("close", cancel); + }); + const answer = await Promise.race([ + readline.question(`\n${CONSENT_TEXT}\n\nSubmit feedback? (y/N) `), + cancelled, + ]); + return /^(?:y|yes)$/i.test(answer.trim()); + } finally { + readline.close(); + } +} diff --git a/src/handlers/feedback/submit.ts b/src/handlers/feedback/submit.ts new file mode 100644 index 000000000..a6369d944 --- /dev/null +++ b/src/handlers/feedback/submit.ts @@ -0,0 +1,305 @@ +import { createHash } from "node:crypto"; +import { stat, readFile } from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { AgentCoreCLIError, ERROR_SOURCE, InputValidationError } from "../../errors"; +import { PACKAGE_VERSION } from "../../constants"; +import type { CoreFetch } from "../../core/types"; +import type { FeedbackSubmissionResult, SubmitFeedbackInput } from "./types"; + +const INGESTION_URL = "https://ingestion.aperture-public-api.feedback.console.aws.dev/form"; +const PRESIGN_URL = + "https://presignedurl.aperture-public-api.feedback.console.aws.dev/presignedurl"; +const FORM_CATEGORY = "AgentCore"; +const FORM_NAME = "CLI"; +const FORM_VERSION = "0.1.0"; +const LOCALE = "en_US"; +const REFERENCE = "agentcore-cli"; +const MESSAGE_QUESTION = "What feedback do you have for the AgentCore CLI"; +const ATTACHMENT_QUESTION = "Attachments"; +const MESSAGE_MAX_LENGTH = 1000; +const MAX_SCREENSHOT_BYTES = 100 * 1024 * 1024; +const ALLOWED_SCREENSHOT_EXTENSIONS = [".png", ".jpg", ".jpeg"] as const; + +export const CONSENT_TEXT = + "All feedback submissions, including any uploaded text and images, are subject " + + "to the AWS Customer Agreement (https://aws.amazon.com/agreement/). By submitting " + + 'feedback, you agree that your submissions constitute "Suggestions" as defined ' + + "in the AWS Customer Agreement."; + +export class ApertureError extends AgentCoreCLIError { + constructor( + message: string, + readonly status?: number, + readonly body?: string, + ) { + super(message, { source: ERROR_SOURCE.SERVICE, name: "ApertureError" }); + } +} + +type LoadedScreenshot = { + buffer: Uint8Array; + fileName: string; + contentType: string; + sha256Base64: string; + size: number; +}; + +type ApertureCustomerResponse = { + question: string; + pii: boolean; + response: + | { responseType: "textArea"; responseValue: string } + | { responseType: "fileUpload"; responseValue: string[] }; +}; + +type ApertureFormPayload = { + category: string; + name: string; + version: string; + locale: string; + reference: string; + location: string; + customerResponses: ApertureCustomerResponse[]; + metadataList: { key: string; value: string }[]; +}; + +export async function submitFeedback( + input: SubmitFeedbackInput, + fetch: CoreFetch, +): Promise { + const message = input.message.trim(); + if (!message) { + throw new InputValidationError("Feedback message cannot be empty."); + } + if (message.length > MESSAGE_MAX_LENGTH) { + throw new InputValidationError( + `Feedback message must be ${MESSAGE_MAX_LENGTH} characters or fewer.`, + ); + } + + const userAgent = `AgentCoreCLI/${PACKAGE_VERSION} (${process.platform} ${os.release()}; node/${process.version})`; + + let screenshotReference: string | undefined; + if (input.screenshot) { + const file = await loadScreenshot(input.screenshot.path); + const presignedUrl = await fetchPresignedUrl( + fetch, + { + category: FORM_CATEGORY, + name: FORM_NAME, + version: FORM_VERSION, + fileName: file.fileName, + fileSize: file.size, + uploadFileSHA256: file.sha256Base64, + }, + userAgent, + ); + screenshotReference = objectKeyFromPresignedUrl(presignedUrl); + await uploadFileToS3( + fetch, + presignedUrl, + file.buffer, + file.contentType, + file.sha256Base64, + userAgent, + ); + } + + const payload = buildFeedbackPayload({ message, screenshotReference }); + return submitForm(fetch, payload, userAgent); +} + +async function fetchPresignedUrl( + fetch: CoreFetch, + request: { + category: string; + name: string; + version: string; + fileName: string; + fileSize: number; + uploadFileSHA256: string; + }, + userAgent: string, +): Promise { + const response = await fetch(PRESIGN_URL, { + method: "POST", + headers: { "content-type": "application/json", "user-agent": userAgent }, + body: JSON.stringify(request), + }); + if (!response.ok) { + throw new ApertureError( + `Failed to fetch screenshot upload URL (HTTP ${response.status}).`, + response.status, + await response.text().catch(() => ""), + ); + } + return (await response.text()).trim(); +} + +async function uploadFileToS3( + fetch: CoreFetch, + presignedUrl: string, + fileBuffer: Uint8Array, + contentType: string, + base64Sha256: string, + userAgent: string, +): Promise { + const response = await fetch(presignedUrl, { + method: "PUT", + headers: { + "content-type": contentType, + "x-amz-checksum-algorithm": "SHA256", + "x-amz-checksum-sha256": base64Sha256, + "x-amz-tagging": "scanstatus=NOT_SCANNED", + "user-agent": userAgent, + }, + body: fileBuffer, + }); + if (!response.ok) { + throw new ApertureError( + `Failed to upload screenshot (HTTP ${response.status}).`, + response.status, + await response.text().catch(() => ""), + ); + } +} + +async function submitForm( + fetch: CoreFetch, + payload: ApertureFormPayload, + userAgent: string, +): Promise { + const response = await fetch(INGESTION_URL, { + method: "POST", + headers: { "content-type": "application/json", "user-agent": userAgent }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new ApertureError(mapStatusToMessage(response.status, body), response.status, body); + } + const data = (await response + .json() + .catch(() => null)) as Partial | null; + if ( + !data || + typeof data.id !== "string" || + typeof data.timestamp !== "string" || + typeof data.reference !== "string" + ) { + throw new ApertureError("Feedback service returned an unexpected response."); + } + return { id: data.id, timestamp: data.timestamp, reference: data.reference }; +} + +async function loadScreenshot(rawFilePath: string): Promise { + const filePath = expandTilde(rawFilePath); + + let stats: Awaited>; + try { + stats = await stat(filePath); + } catch (err) { + throw new InputValidationError( + `Could not read screenshot at ${filePath}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + if (stats.isDirectory()) { + throw new InputValidationError(`Screenshot path is a directory, not a file: ${filePath}`); + } + if (!stats.isFile()) { + throw new InputValidationError(`Screenshot path is not a regular file: ${filePath}`); + } + if (stats.size > MAX_SCREENSHOT_BYTES) { + const sizeMb = (stats.size / (1024 * 1024)).toFixed(1); + throw new InputValidationError(`Screenshot is ${sizeMb} MB; maximum allowed size is 100 MB.`); + } + + const ext = path.extname(filePath).toLowerCase(); + if ( + !ALLOWED_SCREENSHOT_EXTENSIONS.includes(ext as (typeof ALLOWED_SCREENSHOT_EXTENSIONS)[number]) + ) { + throw new InputValidationError( + `Screenshot must be one of: ${ALLOWED_SCREENSHOT_EXTENSIONS.join(", ")}.`, + ); + } + + let buffer: Buffer; + try { + buffer = await readFile(filePath); + } catch (err) { + throw new InputValidationError( + `Could not read screenshot at ${filePath}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + return { + buffer: new Uint8Array(buffer), + fileName: path.basename(filePath), + contentType: ext === ".png" ? "image/png" : "image/jpeg", + sha256Base64: createHash("sha256").update(buffer).digest("base64"), + size: buffer.byteLength, + }; +} + +function expandTilde(filePath: string): string { + if (filePath === "~") return os.homedir(); + if (filePath.startsWith("~/")) return path.join(os.homedir(), filePath.slice(2)); + return filePath; +} + +function objectKeyFromPresignedUrl(presignedUrl: string): string { + try { + return decodeURIComponent(new URL(presignedUrl).pathname.replace(/^\/+/, "")); + } catch { + throw new ApertureError("Feedback service returned an invalid screenshot upload URL."); + } +} + +function buildFeedbackPayload(input: { + message: string; + screenshotReference?: string; +}): ApertureFormPayload { + const customerResponses: ApertureCustomerResponse[] = [ + { + question: MESSAGE_QUESTION, + pii: false, + response: { responseType: "textArea", responseValue: input.message }, + }, + ]; + if (input.screenshotReference) { + customerResponses.push({ + question: ATTACHMENT_QUESTION, + pii: true, + response: { responseType: "fileUpload", responseValue: [input.screenshotReference] }, + }); + } + + return { + category: FORM_CATEGORY, + name: FORM_NAME, + version: FORM_VERSION, + locale: LOCALE, + reference: REFERENCE, + location: `agentcore-cli@${PACKAGE_VERSION} (${process.platform}; node ${process.version}; cli)`, + customerResponses, + metadataList: [ + { key: "cli-version", value: PACKAGE_VERSION }, + { key: "os", value: `${process.platform} ${os.release()}` }, + ], + }; +} + +function mapStatusToMessage(status: number, body: string): string { + switch (status) { + case 400: + return `Feedback service rejected the submission (HTTP 400). ${body || "Form payload may be malformed."}`; + case 412: + return "Feedback service is missing required headers (HTTP 412)."; + case 417: + return "Feedback service rejected the request content type (HTTP 417)."; + case 500: + return "Feedback service returned an internal error (HTTP 500). Please try again later."; + default: + return `Feedback service returned HTTP ${status}.`; + } +} diff --git a/src/handlers/feedback/types.tsx b/src/handlers/feedback/types.tsx new file mode 100644 index 000000000..988370a89 --- /dev/null +++ b/src/handlers/feedback/types.tsx @@ -0,0 +1,12 @@ +export type ScreenshotInput = { path: string }; + +export type SubmitFeedbackInput = { + message: string; + screenshot?: ScreenshotInput; +}; + +export type FeedbackSubmissionResult = { + id: string; + timestamp: string; + reference: string; +}; diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index a2ff49e03..cd03e75f2 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -1,5 +1,6 @@ import { Router } from "../router"; import { createEvalHandler } from "./eval/index.tsx"; +import { createFeedbackHandler } from "./feedback/index.tsx"; import { createGatewayHandler } from "./gateway/index.tsx"; import { createHarnessHandler } from "./harness/index.tsx"; import { createIdentityHandler } from "./identity/index.tsx"; @@ -53,6 +54,7 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router root.handler(createMemoryHandler(core, io)); root.handler(createGatewayHandler(core, io)); root.handler(createEvalHandler(core, io)); + root.handler(createFeedbackHandler(core, io)); root.handler(createConfigHandler()); root.handler(createProjectHandler({ core, io })); diff --git a/src/handlers/root.test.tsx b/src/handlers/root.test.tsx index b3f4e3386..63f5dfc1c 100644 --- a/src/handlers/root.test.tsx +++ b/src/handlers/root.test.tsx @@ -17,6 +17,7 @@ describe("createRootHandler", () => { "memory", "gateway", "eval", + "feedback", "config", "project", ]); diff --git a/src/handlers/types.tsx b/src/handlers/types.tsx index 3d76827b8..570ccb1b0 100644 --- a/src/handlers/types.tsx +++ b/src/handlers/types.tsx @@ -5,6 +5,7 @@ import type { CoreIdentityClient } from "./identity/types.tsx"; import type { CoreMemoryClient } from "./memory/types.tsx"; import type { CoreObservabilityClient, CoreRuntimeClient } from "./runtime/types.tsx"; import type { Context } from "../router"; +import type { CoreFetch } from "../core/types"; import type { ProjectManager } from "./project/types.ts"; import type { DescribeBedrockAgent } from "../core/project/bedrockAgent"; @@ -19,6 +20,8 @@ export interface Core { projectManager: ProjectManager; /** Describes a Bedrock Agent + alias for `--type import`. */ describeBedrockAgent: DescribeBedrockAgent; + /** Shared outbound HTTP for handlers that call non-AWS APIs directly (e.g. feedback → Aperture). */ + fetch: CoreFetch; } // ScreenProps is the common prop set every TUI screen receives. `ctx` carries the diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index a6da20a9e..ce526cc4f 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -168,7 +168,7 @@ import type { } from "../handlers/eval/types"; import { isTerminalStatus } from "../core/batchEvaluationResults"; import { abortable } from "../core/abortable"; -import type { CoreOptions, CreateCloudFormationClient } from "../core/types"; +import type { CoreFetch, CoreOptions, CreateCloudFormationClient } from "../core/types"; import type { Project, ProjectManager } from "../handlers/project/types"; import type { Logger } from "../logging"; import type { ReadWriteJson } from "../io"; @@ -2306,6 +2306,9 @@ export class TestCoreClient implements Core { readonly gateway = new TestGatewayClient(); readonly eval = new TestEvalClient(); readonly observability = new TestObservabilityClient(); + fetch: CoreFetch = (async () => { + throw new Error("TestCoreClient.fetch is not configured; set it in the test that needs it"); + }) as unknown as CoreFetch; readonly projectManager: ProjectManager; // Commands the project manager would have run (npm install, git init, ...), diff --git a/src/testing/fixtures.tsx b/src/testing/fixtures.tsx index 2ea230002..663e7ff89 100644 --- a/src/testing/fixtures.tsx +++ b/src/testing/fixtures.tsx @@ -302,15 +302,16 @@ export function fixtureFetch(dir: string): CoreFetch { if (isRecording()) { mkdirSync(dir, { recursive: true }); const response = await globalThis.fetch(input, init); + const body = await response.text(); const fixture: FetchFixture = { status: response.status, statusText: response.statusText, - body: await response.text(), + body: sanitizePresignedUrls(body), }; writeFileSync(path, stringify(fixture)); - return new Response(fixture.body, { - status: fixture.status, - statusText: fixture.statusText, + return new Response(body, { + status: response.status, + statusText: response.statusText, }); }