From 8e1c9ccba709618be3eeac6302177bdc9efe4d8a Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 2 Sep 2026 18:50:49 -0400 Subject: [PATCH 1/5] refactor(components): promote ErrorPanel and EventLog for reuse --- src/components/ErrorPanel.tsx | 17 +++++++++++++++++ src/components/EventLog.tsx | 16 ++++++++++++++++ src/components/HarnessWizard.tsx | 14 +------------- src/handlers/project/create/screen.tsx | 13 +------------ 4 files changed, 35 insertions(+), 25 deletions(-) create mode 100644 src/components/ErrorPanel.tsx create mode 100644 src/components/EventLog.tsx diff --git a/src/components/ErrorPanel.tsx b/src/components/ErrorPanel.tsx new file mode 100644 index 000000000..48add5c14 --- /dev/null +++ b/src/components/ErrorPanel.tsx @@ -0,0 +1,17 @@ +import { Box, Text, useInput } from "ink"; +import { darkTheme } from "./ui/_core.js"; + +const theme = darkTheme; + +export function ErrorPanel({ message, onBack }: { message: string; onBack: () => void }) { + useInput((_input, key) => { + if (key.escape || key.return) onBack(); + }); + + return ( + + ✗ {message} + {" esc returns to the form"} + + ); +} diff --git a/src/components/EventLog.tsx b/src/components/EventLog.tsx new file mode 100644 index 000000000..4e43d960f --- /dev/null +++ b/src/components/EventLog.tsx @@ -0,0 +1,16 @@ +import { Box, Text } from "ink"; +import { darkTheme } from "./ui/_core.js"; + +const theme = darkTheme; + +export function EventLog({ events }: { events: string[] }) { + return ( + + {events.map((message, index) => ( + + ✓ {message} + + ))} + + ); +} diff --git a/src/components/HarnessWizard.tsx b/src/components/HarnessWizard.tsx index 1e69b460f..a86573ffe 100644 --- a/src/components/HarnessWizard.tsx +++ b/src/components/HarnessWizard.tsx @@ -11,6 +11,7 @@ import type { CreateHarnessInput } from "../handlers/harness/types"; import type { ScreenProps } from "../handlers/types"; import { coreOptsFromCtx } from "../handlers/utils"; import { Layout } from "./Layout"; +import { ErrorPanel } from "./ErrorPanel"; import { FormTextInput } from "./FormTextInput"; import { FormRadioGroup, type FormRadioOption } from "./FormRadioGroup"; import { FormCheckboxMultiSelect, type FormCheckboxOption } from "./FormCheckboxMultiSelect"; @@ -1188,16 +1189,3 @@ function SuccessPanel({ ); } - -function ErrorPanel({ message, onBack }: { message: string; onBack: () => void }) { - useInput((_input, key) => { - if (key.escape || key.return) onBack(); - }); - - return ( - - ✗ {message} - {" esc returns to the form"} - - ); -} diff --git a/src/handlers/project/create/screen.tsx b/src/handlers/project/create/screen.tsx index 55048312a..fc4c3d498 100644 --- a/src/handlers/project/create/screen.tsx +++ b/src/handlers/project/create/screen.tsx @@ -14,6 +14,7 @@ import { } from "../shortcuts"; import { resolveScaffoldHarnessInput } from "./index"; import { Layout } from "../../../components/Layout"; +import { EventLog } from "../../../components/EventLog"; import { FormTextInput } from "../../../components/FormTextInput"; import { FormRadioGroup, type FormRadioOption } from "../../../components/FormRadioGroup"; import { KeyValueTable } from "../../../components/KeyValueTable"; @@ -782,18 +783,6 @@ function ReviewStep({ // ─── result panels ──────────────────────────────────────────────────────────── -function EventLog({ events }: { events: string[] }) { - return ( - - {events.map((message, index) => ( - - ✓ {message} - - ))} - - ); -} - function SuccessPanel({ name, onContinue }: { name: string; onContinue: () => void }) { useInput((_input, key) => { if (key.return || key.escape) onContinue(); From 63c660a44201566553ef43337596bd2649b6b0e7 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 2 Sep 2026 18:51:48 -0400 Subject: [PATCH 2/5] feat(gateway): policy generate joins the TUI router and deep-links a gateway id --- src/handlers/gateway/gateway.test.tsx | 34 +++++++++++++++++++++++- src/handlers/gateway/index.tsx | 2 +- src/handlers/gateway/policy/generate.tsx | 26 +++++++++++++++--- src/handlers/gateway/policy/index.tsx | 8 +++--- src/testing/TestCoreClient.tsx | 21 ++++++++------- 5 files changed, 73 insertions(+), 18 deletions(-) diff --git a/src/handlers/gateway/gateway.test.tsx b/src/handlers/gateway/gateway.test.tsx index f61218b05..c92f18057 100644 --- a/src/handlers/gateway/gateway.test.tsx +++ b/src/handlers/gateway/gateway.test.tsx @@ -15,6 +15,9 @@ import { } from "../../testing"; import { compile, isTuiCommandSupported, ValueContext } from "../../router"; import { createRootHandler } from "../index"; +import { PathKey } from "../../router"; +import { JsonKey } from "../keys"; +import { createGeneratePolicyHandler } from "./policy/generate"; import type { Core } from "../types"; const REGION = "us-west-2"; @@ -148,7 +151,7 @@ describe("gateway validation", () => { ["Policy generate gateway", ["gateway", "policy", "generate", "--prompt", "x"], /--gateway-id/], [ "Policy generate prompt", - ["gateway", "policy", "generate", "--gateway-id", GATEWAY_ID], + ["gateway", "policy", "generate", "--gateway-id", GATEWAY_ID, "--json"], /--prompt/, ], ] as const)( @@ -218,3 +221,32 @@ describe("gateway policy generate against a faked control plane", () => { await expect(attempt).rejects.toThrow("did not finish within 2s"); }, 10_000); }); + +describe("gateway policy generate deep link", () => { + test.each([ + ["opens the form when only --gateway-id is given", { "gateway-id": "gw-1" }, 1], + ["stays headless when --name is also given", { "gateway-id": "gw-1", name: "n" }, 0], + ])("%s", async (_label, flags, expectedRenders) => { + const core = new TestCoreClient(); + let renders = 0; + const handler = createGeneratePolicyHandler( + core, + testIO().io, + async (path, _ctx, renderedCore) => { + renders++; + expect(path).toBe("/agentcore/gateway/policy/generate/gw-1"); + expect(renderedCore).toBe(core); + }, + ); + const ctx = ValueContext.EmptyContext() + .withValue(PathKey, "/agentcore/gateway/policy/generate") + .withValue(JsonKey, false); + + const attempt = handler.handle(ctx, { prompt: undefined, ...flags }, {}); + if (expectedRenders === 0) await expect(attempt).rejects.toThrow(/--prompt/); + else await attempt; + + expect(renders).toBe(expectedRenders); + expect(core.policy.calls).toEqual([]); + }); +}); diff --git a/src/handlers/gateway/index.tsx b/src/handlers/gateway/index.tsx index 62b3374b5..466065be7 100644 --- a/src/handlers/gateway/index.tsx +++ b/src/handlers/gateway/index.tsx @@ -18,7 +18,7 @@ export function createGatewayHandler(core: Core, io: AppIO): Router { return new Router("gateway", "inspect AgentCore Gateways") .use(withTuiOnEmptyFlagsAndArgs(core, io)) .default(renderTui(core, io)) - .supportedTuiCommands("get", "list", "invoke", "target", "connector", "rule") + .supportedTuiCommands("get", "list", "invoke", "target", "connector", "rule", "policy") .handler(createCreateGatewayHandler(core, io)) .handler(createUpdateGatewayHandler(core, io)) .handler(createGetGatewayHandler(core)) diff --git a/src/handlers/gateway/policy/generate.tsx b/src/handlers/gateway/policy/generate.tsx index 3557c71fa..11dbdedfa 100644 --- a/src/handlers/gateway/policy/generate.tsx +++ b/src/handlers/gateway/policy/generate.tsx @@ -1,15 +1,19 @@ import z from "zod"; import { InputValidationError } from "../../../errors"; import { type AppIO, SourceResolver } from "../../../io"; -import { createHandler, flag } from "../../../router"; -import { JsonRendererKey } from "../../../tui"; +import { createHandler, flag, PathKey } from "../../../router"; +import { JsonRendererKey, renderTuiAt } from "../../../tui"; import { runWithProgress } from "../../../tui/progress"; import { JsonKey } from "../../keys"; import type { Core } from "../../types"; import { coreOptsFromCtx, renderJsonError } from "../../utils"; import type { PolicyGenerationResult } from "./types"; -export const createGeneratePolicyHandler = (core: Core, io: AppIO) => +export const createGeneratePolicyHandler = ( + core: Core, + io: AppIO, + renderGenerateTui: typeof renderTuiAt = renderTuiAt, +) => createHandler({ name: "generate", description: "generate a Cedar policy for a Gateway from a natural-language prompt", @@ -39,6 +43,21 @@ export const createGeneratePolicyHandler = (core: Core, io: AppIO) => if (!flags["gateway-id"]) { throw new InputValidationError("required option '--gateway-id ' not specified"); } + const jsonOutput = ctx.require(JsonKey); + if ( + flags.prompt === undefined && + !jsonOutput && + flags["policy-engine-id"] === undefined && + flags.name === undefined + ) { + await renderGenerateTui( + `${ctx.require(PathKey)}/${encodeURIComponent(flags["gateway-id"])}`, + ctx, + core, + io, + ); + return; + } if (flags.prompt === undefined) { throw new InputValidationError("required option '--prompt ' not specified"); } @@ -46,7 +65,6 @@ export const createGeneratePolicyHandler = (core: Core, io: AppIO) => "prompt", flags.prompt, ))!; - const jsonOutput = ctx.require(JsonKey); const generation = core.policy.generatePolicy( { diff --git a/src/handlers/gateway/policy/index.tsx b/src/handlers/gateway/policy/index.tsx index 50efe78ec..778685080 100644 --- a/src/handlers/gateway/policy/index.tsx +++ b/src/handlers/gateway/policy/index.tsx @@ -1,10 +1,12 @@ import type { AppIO } from "../../../io"; import { Router } from "../../../router"; +import { renderTui } from "../../../tui"; import type { Core } from "../../types"; import { createGeneratePolicyHandler } from "./generate"; export function createGatewayPolicyHandler(core: Core, io: AppIO): Router { - return new Router("policy", "generate Cedar policies for an AgentCore Gateway").handler( - createGeneratePolicyHandler(core, io), - ); + return new Router("policy", "generate Cedar policies for an AgentCore Gateway") + .default(renderTui(core, io)) + .supportedTuiCommands("generate") + .handler(createGeneratePolicyHandler(core, io)); } diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index f4e1e013f..95db03170 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -2404,21 +2404,24 @@ export class TestObservabilityClient implements CoreObservabilityClient { } export class TestPolicyClient implements CorePolicyClient { + result: PolicyGenerationResult = { + policyGenerationId: "gen-1", + policyEngineId: "pe-1", + gatewayArn: "arn:aws:bedrock-agentcore:us-west-2:111122223333:gateway/gw-1", + policies: [ + { statement: "forbid (principal, action, resource is AgentCore::Gateway);", findings: [] }, + ], + }; + error: Error | undefined; readonly calls: GeneratePolicyInput[] = []; async *generatePolicy( input: GeneratePolicyInput, ): AsyncGenerator { this.calls.push(input); - yield { type: "step", message: "Generating policy" }; - return { - policyGenerationId: "gen-1", - policyEngineId: "pe-1", - gatewayArn: "arn:aws:bedrock-agentcore:us-west-2:111122223333:gateway/gw-1", - policies: [ - { statement: "forbid (principal, action, resource is AgentCore::Gateway);", findings: [] }, - ], - }; + yield { type: "step", message: "Resolving gateway" }; + if (this.error) throw this.error; + return this.result; } } From 26d47f4b83934882ac823b6dff6067aab9c77c7c Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 2 Sep 2026 18:54:51 -0400 Subject: [PATCH 3/5] feat(gateway): interactive screen for policy generate --- README.md | 2 +- src/components/Root.tsx | 13 ++ src/handlers/gateway/gateway.screen.test.tsx | 2 +- .../gateway/policy/generate.screen.test.tsx | 110 +++++++++++ src/handlers/gateway/policy/screen.tsx | 186 ++++++++++++++++++ 5 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 src/handlers/gateway/policy/generate.screen.test.tsx create mode 100644 src/handlers/gateway/policy/screen.tsx diff --git a/README.md b/README.md index 6b7f37f70..41a92ba40 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ agentcore # interactive TUI │ │ ├── get # get a Rule under a Gateway │ │ └── list # list Rules under a Gateway │ └── policy -│ └── generate # generate Cedar for a Gateway from a natural-language prompt +│ └── generate # generate Cedar for a Gateway from a prompt (TUI when run bare) ├── eval # evaluate and optimize AgentCore agents │ └── evaluator # manage AgentCore evaluators │ ├── llm-as-a-judge # LLM-as-a-Judge evaluators diff --git a/src/components/Root.tsx b/src/components/Root.tsx index b05b27bef..6b33779ed 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -107,6 +107,7 @@ import { GatewayRuleScreen } from "../handlers/gateway/rule/screen.tsx"; import { GatewayRuleListScreen } from "../handlers/gateway/rule/list/screen.tsx"; import { GatewayRuleGetScreen } from "../handlers/gateway/rule/get/screen.tsx"; import { GatewayInvokeScreen } from "../handlers/gateway/invoke/screen.tsx"; +import { GatewayPolicyGenerateScreen } from "../handlers/gateway/policy/screen.tsx"; import { ProjectScreen, ProjectCommandNotImplementedScreen } from "../handlers/project/screen.tsx"; import { ProjectCreateScreen } from "../handlers/project/create/screen.tsx"; import { ProjectInvokePickerScreen } from "../handlers/project/invoke/screen.tsx"; @@ -464,6 +465,18 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/gateway/rule/get/:gatewayId/:ruleId" element={} /> + } + /> + } + /> + } + /> } /> { await waitForText(screen.lastFrame, "inspect AgentCore Gateways"); const frame = screen.lastFrame()!; - for (const command of ["get", "list", "invoke", "target", "connector", "rule"]) { + for (const command of ["get", "list", "invoke", "target", "connector", "rule", "policy"]) { expect(frame).toContain(command); } expect(frame).not.toMatch(/\bcreate\b/); diff --git a/src/handlers/gateway/policy/generate.screen.test.tsx b/src/handlers/gateway/policy/generate.screen.test.tsx new file mode 100644 index 000000000..08f789304 --- /dev/null +++ b/src/handlers/gateway/policy/generate.screen.test.tsx @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { GatewaySummary, GetGatewayResponse } from "@aws-sdk/client-bedrock-agentcore-control"; +import { cleanupScreens, renderScreen, TestCoreClient, waitForText } from "../../../testing"; + +afterEach(cleanupScreens); + +const GATEWAY_ID = "gw-1"; +const ENGINE_ARN = "arn:aws:bedrock-agentcore:us-east-1:123456789012:policy-engine/pe-1"; +const FORBID = + "forbid (principal is AgentCore::IamEntity, action, resource is AgentCore::Gateway);"; +const PERMIT = "permit (principal, action, resource is AgentCore::Gateway);"; +const PLACEHOLDER = "Describe what the policy should allow or deny"; + +function coreWith(engineArn: string | undefined): TestCoreClient { + const core = new TestCoreClient(); + const summary: GatewaySummary = { + gatewayId: GATEWAY_ID, + name: "checkout-gateway", + status: "READY", + createdAt: new Date("2026-08-01T01:02:03.000Z"), + updatedAt: new Date("2026-08-02T03:04:05.000Z"), + authorizerType: "AWS_IAM", + }; + core.gateway.setListResponse({ items: [summary] }); + core.gateway.setGetResponse({ + gatewayId: GATEWAY_ID, + name: "checkout-gateway", + gatewayArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/gw-1", + policyEngineConfiguration: engineArn ? { arn: engineArn, mode: "ENFORCE" } : undefined, + } as GetGatewayResponse); + return core; +} + +describe("gateway policy generate screen", () => { + test("picks a gateway, then shows its engine and the prompt", async () => { + const screen = renderScreen("/agentcore/gateway/policy/generate", { + core: coreWith(ENGINE_ARN), + }); + + await waitForText(screen.lastFrame, "checkout-gateway"); + await screen.press("return"); + + await waitForText(screen.lastFrame, ENGINE_ARN); + const frame = screen.lastFrame()!; + expect(frame).toContain(`agentcore → gateway → policy → generate → ${GATEWAY_ID}`); + expect(frame).toContain(PLACEHOLDER); + expect(frame).toContain("[enter] generate"); + }); + + test("explains when the gateway has no engine and offers no prompt", async () => { + const screen = renderScreen(`/agentcore/gateway/policy/generate/${GATEWAY_ID}`, { + core: coreWith(undefined), + }); + + await waitForText(screen.lastFrame, "no Policy Engine attached"); + expect(screen.lastFrame()).not.toContain(PLACEHOLDER); + }); + + test("generates, renders the Cedar and findings, and edits again on e", async () => { + const core = coreWith(ENGINE_ARN); + core.policy.result = { + policyGenerationId: "gen-1", + policyEngineId: "pe-1", + gatewayArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/gw-1", + policies: [ + { + statement: FORBID, + findings: [{ type: "DENY_ALL", description: "denies every request" }], + }, + { statement: PERMIT, findings: [] }, + ], + }; + const screen = renderScreen(`/agentcore/gateway/policy/generate/${GATEWAY_ID}`, { core }); + await waitForText(screen.lastFrame, PLACEHOLDER); + + await screen.write("forbid IAM callers"); + await screen.press("return"); + + await waitForText(screen.lastFrame, PERMIT, 3000); + const frame = screen.lastFrame()!; + expect(frame).toContain(FORBID); + expect(frame).toContain("✓ Resolving gateway"); + expect(frame).toContain("[DENY_ALL] denies every request"); + expect(frame).toContain("[e] edit prompt"); + expect(core.policy.calls[0]).toMatchObject({ + gatewayId: GATEWAY_ID, + prompt: "forbid IAM callers", + }); + expect(core.policy.calls[0]).not.toHaveProperty("policyEngineId"); + expect(core.policy.calls[0]!.name).toMatch(/^cli_generation_\d+$/); + + await screen.write("e"); + await waitForText(screen.lastFrame, "[enter] generate"); + expect(screen.lastFrame()).toContain("forbid IAM callers"); + }); + + test("shows the error and returns to the form on esc", async () => { + const core = coreWith(ENGINE_ARN); + core.policy.error = new Error("policy generation 'gen-1' failed: bad prompt"); + const screen = renderScreen(`/agentcore/gateway/policy/generate/${GATEWAY_ID}`, { core }); + await waitForText(screen.lastFrame, PLACEHOLDER); + + await screen.write("x"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "✗ policy generation 'gen-1' failed: bad prompt", 3000); + await screen.press("escape"); + await waitForText(screen.lastFrame, "[enter] generate"); + }); +}); diff --git a/src/handlers/gateway/policy/screen.tsx b/src/handlers/gateway/policy/screen.tsx new file mode 100644 index 000000000..737fcadde --- /dev/null +++ b/src/handlers/gateway/policy/screen.tsx @@ -0,0 +1,186 @@ +import { useEffect, useRef, useState } from "react"; +import { Box, Text, useInput } from "ink"; +import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; +import { useQuery } from "@tanstack/react-query"; +import { useNavigate, useParams } from "react-router"; +import { ErrorPanel } from "../../../components/ErrorPanel"; +import { EventLog } from "../../../components/EventLog"; +import { GatewayPicker } from "../../../components/GatewayPicker"; +import { KeyValueTable } from "../../../components/KeyValueTable"; +import { Layout } from "../../../components/Layout"; +import { MultilineInput } from "../../../components/MultilineInput"; +import { Divider } from "../../../components/ui/divider"; +import { Spinner } from "../../../components/ui/spinner"; +import { darkTheme } from "../../../components/ui/_core.js"; +import type { ScreenProps } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; +import type { PolicyGenerationResult } from "./types"; + +const theme = darkTheme; +const PROMPT_PLACEHOLDER = "Describe what the policy should allow or deny"; + +type Phase = + | { kind: "form" } + | { kind: "running" } + | { kind: "result"; result: PolicyGenerationResult } + | { kind: "error"; message: string }; + +export function GatewayPolicyGenerateScreen(props: ScreenProps) { + const { gatewayId } = useParams(); + const navigate = useNavigate(); + + if (!gatewayId) { + return ( + navigate(`/agentcore/gateway/policy/generate/${encodeURIComponent(id)}`)} + /> + ); + } + return ; +} + +function GeneratePolicyForm({ ctx, core, gatewayId }: ScreenProps & { gatewayId: string }) { + const navigate = useNavigate(); + const opts = coreOptsFromCtx(ctx); + const gateway = useQuery({ + queryKey: ["gateway", opts.region, gatewayId], + queryFn: () => core.gateway.getGateway(gatewayId, opts), + }); + const engineArn = gateway.data?.policyEngineConfiguration?.arn; + + const [phase, setPhase] = useState({ kind: "form" }); + const [prompt, setPrompt] = useState(""); + const [events, setEvents] = useState([]); + const scrollRef = useRef(null); + const aliveRef = useRef(true); + useEffect(() => { + aliveRef.current = true; + return () => { + aliveRef.current = false; + }; + }, []); + + const submit = async () => { + setEvents([]); + setPhase({ kind: "running" }); + try { + const generation = core.policy.generatePolicy( + { gatewayId, prompt, name: `cli_generation_${Date.now()}` }, + opts, + ); + let next = await generation.next(); + while (!next.done) { + if (!aliveRef.current) return; + if (next.value.type === "step") { + const message = next.value.message; + setEvents((current) => [...current, message]); + } + next = await generation.next(); + } + if (aliveRef.current) setPhase({ kind: "result", result: next.value }); + } catch (error) { + if (aliveRef.current) setPhase({ kind: "error", message: (error as Error).message }); + } + }; + + useInput( + (input, key) => { + if (key.escape) { + navigate(-1); + return; + } + if (phase.kind !== "result") return; + if (input === "e") setPhase({ kind: "form" }); + if (key.upArrow || input === "k") scrollRef.current?.scrollBy(-1); + if (key.downArrow || input === "j") scrollRef.current?.scrollBy(1); + }, + { isActive: phase.kind !== "error" }, + ); + + const keyHints = + phase.kind === "form" && engineArn + ? [ + { key: "enter", label: "generate" }, + { key: "⇧↵", label: "newline" }, + { key: "esc", label: "back" }, + { key: "ctl+c", label: "quit" }, + ] + : phase.kind === "result" + ? [ + { key: "↑↓/kj", label: "scroll" }, + { key: "e", label: "edit prompt" }, + { key: "esc", label: "back" }, + { key: "ctl+c", label: "quit" }, + ] + : [ + { key: "esc", label: "back" }, + { key: "ctl+c", label: "quit" }, + ]; + + return ( + + {gateway.isPending ? ( + + ) : gateway.isError ? ( + Error: {(gateway.error as Error).message} + ) : ( + + + + {!engineArn ? ( + + This Gateway has no Policy Engine attached. Attach one and deploy, then come back. + + ) : phase.kind === "form" ? ( + void submit()} + placeholder={PROMPT_PLACEHOLDER} + submitDisabled={prompt.trim().length === 0} + /> + ) : phase.kind === "running" ? ( + + + + + ) : phase.kind === "error" ? ( + setPhase({ kind: "form" })} /> + ) : ( + + + + {phase.result.policies.flatMap((policy, index) => + policy.statement + ? [ + {policy.statement.trimEnd()}, + , + ] + : [], + )} + {phase.result.policies.flatMap((policy, index) => + policy.findings.map((finding, findingIndex) => ( + + [{finding.type}] {finding.description} + + )), + )} + + )} + + )} + + ); +} From 0006f612d7d8793225bb157792c9280430036e51 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 2 Sep 2026 18:55:51 -0400 Subject: [PATCH 4/5] refactor(policy): trim the result render and alive guard --- src/handlers/gateway/policy/screen.tsx | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/handlers/gateway/policy/screen.tsx b/src/handlers/gateway/policy/screen.tsx index 737fcadde..2c8ca3adb 100644 --- a/src/handlers/gateway/policy/screen.tsx +++ b/src/handlers/gateway/policy/screen.tsx @@ -56,12 +56,12 @@ function GeneratePolicyForm({ ctx, core, gatewayId }: ScreenProps & { gatewayId: const [events, setEvents] = useState([]); const scrollRef = useRef(null); const aliveRef = useRef(true); - useEffect(() => { - aliveRef.current = true; - return () => { + useEffect( + () => () => { aliveRef.current = false; - }; - }, []); + }, + [], + ); const submit = async () => { setEvents([]); @@ -162,14 +162,11 @@ function GeneratePolicyForm({ ctx, core, gatewayId }: ScreenProps & { gatewayId: - {phase.result.policies.flatMap((policy, index) => - policy.statement - ? [ - {policy.statement.trimEnd()}, - , - ] - : [], - )} + + {phase.result.policies + .flatMap((policy) => (policy.statement ? [policy.statement.trimEnd()] : [])) + .join("\n\n")} + {phase.result.policies.flatMap((policy, index) => policy.findings.map((finding, findingIndex) => ( From 9e3946ef8ed5ef5d88739255979ff76c78343345 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Thu, 3 Sep 2026 12:52:00 -0400 Subject: [PATCH 5/5] fix(policy): abort generation on esc, navigate to the picker explicitly, render steps with TaskList --- src/components/EventLog.tsx | 16 ---- src/core/policy.tsx | 20 ++++- src/handlers/gateway/gateway.test.tsx | 17 +++- .../gateway/policy/generate.screen.test.tsx | 15 ++++ src/handlers/gateway/policy/screen.tsx | 79 ++++++++++++------- src/handlers/gateway/policy/types.tsx | 1 + src/handlers/project/create/screen.tsx | 13 ++- src/testing/TestCoreClient.tsx | 11 +++ 8 files changed, 124 insertions(+), 48 deletions(-) delete mode 100644 src/components/EventLog.tsx diff --git a/src/components/EventLog.tsx b/src/components/EventLog.tsx deleted file mode 100644 index 4e43d960f..000000000 --- a/src/components/EventLog.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Box, Text } from "ink"; -import { darkTheme } from "./ui/_core.js"; - -const theme = darkTheme; - -export function EventLog({ events }: { events: string[] }) { - return ( - - {events.map((message, index) => ( - - ✓ {message} - - ))} - - ); -} diff --git a/src/core/policy.tsx b/src/core/policy.tsx index 6ac4b722a..efbf663a5 100644 --- a/src/core/policy.tsx +++ b/src/core/policy.tsx @@ -6,7 +6,13 @@ import { type GetPolicyGenerationCommandOutput, } from "@aws-sdk/client-bedrock-agentcore-control"; import { WaiterState } from "@smithy/core/client"; -import { AgentCoreCLIError, ERROR_SOURCE, InputValidationError, NetworkingError } from "../errors"; +import { + AgentCoreCLIError, + ERROR_SOURCE, + InputValidationError, + NetworkingError, + UserCancellationError, +} from "../errors"; import type { CorePolicyClient, GeneratedPolicy, @@ -40,12 +46,15 @@ export class PolicyClient implements CorePolicyClient { async *generatePolicy( input: GeneratePolicyInput, options: CoreOptions, + signal?: AbortSignal, ): AsyncGenerator { const control = this.clients.control(toClientConfig(options)); const gatewayId = resourceIdFromArn(input.gatewayId); yield { type: "step", message: `Resolving gateway ${gatewayId}` }; - const gateway = await control.send(new GetGatewayCommand({ gatewayIdentifier: gatewayId })); + const gateway = await control.send(new GetGatewayCommand({ gatewayIdentifier: gatewayId }), { + abortSignal: signal, + }); const gatewayArn = gateway.gatewayArn!; const engine = input.policyEngineId ?? gateway.policyEngineConfiguration?.arn; if (!engine) { @@ -63,16 +72,20 @@ export class PolicyClient implements CorePolicyClient { content: { rawText: input.prompt }, name: input.name, }), + { abortSignal: signal }, ); const policyGenerationId = started.policyGenerationId!; const meta = { policyGenerationId, policyEngineId }; yield { type: "step", message: "Waiting for generation to complete" }; const waited = await waitForPolicyGenerationCompleted( - { client: control, ...this.wait }, + { client: control, abortSignal: signal, ...this.wait }, { policyEngineId, policyGenerationId }, ); this.logger.debug(`policy generation ${policyGenerationId} waiter state: ${waited.state}`); + if (waited.state === WaiterState.ABORTED) { + throw signal?.reason ?? new UserCancellationError(); + } if (waited.state === WaiterState.TIMEOUT) { throw new NetworkingError( `policy generation '${policyGenerationId}' did not finish within ${this.wait.maxWaitTime}s; ` + @@ -95,6 +108,7 @@ export class PolicyClient implements CorePolicyClient { do { const page = await control.send( new ListPolicyGenerationAssetsCommand({ policyEngineId, policyGenerationId, nextToken }), + { abortSignal: signal }, ); for (const asset of page.policyGenerationAssets ?? []) { policies.push({ diff --git a/src/handlers/gateway/gateway.test.tsx b/src/handlers/gateway/gateway.test.tsx index c92f18057..2c25b1865 100644 --- a/src/handlers/gateway/gateway.test.tsx +++ b/src/handlers/gateway/gateway.test.tsx @@ -6,7 +6,7 @@ import { } from "@aws-sdk/client-bedrock-agentcore-control"; import { PolicyClient } from "../../core/policy"; import type { AwsClients } from "../../core/types"; -import { NetworkingError } from "../../errors"; +import { NetworkingError, UserCancellationError } from "../../errors"; import { createSilentLogger, TestCoreClient, @@ -215,6 +215,21 @@ describe("gateway policy generate against a faked control plane", () => { ).rejects.toThrow("policy generation 'gen-1' failed: bad prompt; try again"); }); + test("stops waiting when the signal aborts", async () => { + const generation = coreWith("GENERATING").policy.generatePolicy( + { gatewayId: GATEWAY_ID, prompt: "x", name: "n" }, + { region: REGION }, + AbortSignal.abort(new UserCancellationError()), + ); + await expect( + (async () => { + for await (const _event of generation) { + // drain + } + })(), + ).rejects.toBeInstanceOf(UserCancellationError); + }); + test("times out when the generation keeps running", async () => { const attempt = run(args, coreWith("GENERATING")); await expect(attempt).rejects.toBeInstanceOf(NetworkingError); diff --git a/src/handlers/gateway/policy/generate.screen.test.tsx b/src/handlers/gateway/policy/generate.screen.test.tsx index 08f789304..1e3855abc 100644 --- a/src/handlers/gateway/policy/generate.screen.test.tsx +++ b/src/handlers/gateway/policy/generate.screen.test.tsx @@ -94,6 +94,21 @@ describe("gateway policy generate screen", () => { expect(screen.lastFrame()).toContain("forbid IAM callers"); }); + test("aborts the run and returns to the picker on esc while generating", async () => { + const core = coreWith(ENGINE_ARN); + core.policy.hang = true; + const screen = renderScreen(`/agentcore/gateway/policy/generate/${GATEWAY_ID}`, { core }); + await waitForText(screen.lastFrame, PLACEHOLDER); + + await screen.write("x"); + await screen.press("return"); + await waitForText(screen.lastFrame, "[esc] cancel"); + + await screen.press("escape"); + await waitForText(screen.lastFrame, "choose a Gateway to generate a policy for"); + expect(core.policy.signals[0]!.aborted).toBe(true); + }); + test("shows the error and returns to the form on esc", async () => { const core = coreWith(ENGINE_ARN); core.policy.error = new Error("policy generation 'gen-1' failed: bad prompt"); diff --git a/src/handlers/gateway/policy/screen.tsx b/src/handlers/gateway/policy/screen.tsx index 2c8ca3adb..e8dc680c6 100644 --- a/src/handlers/gateway/policy/screen.tsx +++ b/src/handlers/gateway/policy/screen.tsx @@ -4,19 +4,21 @@ import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; import { useQuery } from "@tanstack/react-query"; import { useNavigate, useParams } from "react-router"; import { ErrorPanel } from "../../../components/ErrorPanel"; -import { EventLog } from "../../../components/EventLog"; import { GatewayPicker } from "../../../components/GatewayPicker"; import { KeyValueTable } from "../../../components/KeyValueTable"; import { Layout } from "../../../components/Layout"; import { MultilineInput } from "../../../components/MultilineInput"; import { Divider } from "../../../components/ui/divider"; import { Spinner } from "../../../components/ui/spinner"; +import { TaskList, type Task } from "../../../components/ui/task-list"; import { darkTheme } from "../../../components/ui/_core.js"; +import { UserCancellationError } from "../../../errors"; import type { ScreenProps } from "../../types"; import { coreOptsFromCtx } from "../../utils"; import type { PolicyGenerationResult } from "./types"; const theme = darkTheme; +const PICKER_PATH = "/agentcore/gateway/policy/generate"; const PROMPT_PLACEHOLDER = "Describe what the policy should allow or deny"; type Phase = @@ -35,13 +37,17 @@ export function GatewayPolicyGenerateScreen(props: ScreenProps) { {...props} breadcrumb={["agentcore", "gateway", "policy", "generate"]} description="choose a Gateway to generate a policy for" - onSelect={(id) => navigate(`/agentcore/gateway/policy/generate/${encodeURIComponent(id)}`)} + onSelect={(id) => navigate(`${PICKER_PATH}/${encodeURIComponent(id)}`)} /> ); } return ; } +function finishTasks(tasks: Task[], state: Task["state"]): Task[] { + return tasks.map((task, index) => (index === tasks.length - 1 ? { ...task, state } : task)); +} + function GeneratePolicyForm({ ctx, core, gatewayId }: ScreenProps & { gatewayId: string }) { const navigate = useNavigate(); const opts = coreOptsFromCtx(ctx); @@ -53,43 +59,62 @@ function GeneratePolicyForm({ ctx, core, gatewayId }: ScreenProps & { gatewayId: const [phase, setPhase] = useState({ kind: "form" }); const [prompt, setPrompt] = useState(""); - const [events, setEvents] = useState([]); + const [tasks, setTasks] = useState([]); const scrollRef = useRef(null); - const aliveRef = useRef(true); - useEffect( - () => () => { - aliveRef.current = false; - }, - [], - ); + const runRef = useRef<{ + controller: AbortController; + generation: AsyncGenerator; + }>(null); + + const cancel = () => { + const run = runRef.current; + if (!run) return; + runRef.current = null; + run.controller.abort(new UserCancellationError()); + void run.generation.return(undefined as never); + }; + useEffect(() => cancel, []); const submit = async () => { - setEvents([]); + const controller = new AbortController(); + const generation = core.policy.generatePolicy( + { gatewayId, prompt, name: `cli_generation_${Date.now()}` }, + opts, + controller.signal, + ); + runRef.current = { controller, generation }; + setTasks([]); setPhase({ kind: "running" }); try { - const generation = core.policy.generatePolicy( - { gatewayId, prompt, name: `cli_generation_${Date.now()}` }, - opts, - ); let next = await generation.next(); while (!next.done) { - if (!aliveRef.current) return; + if (controller.signal.aborted) return; if (next.value.type === "step") { - const message = next.value.message; - setEvents((current) => [...current, message]); + const title = next.value.message; + setTasks((current) => [ + ...finishTasks(current, "done"), + { title, state: "running", tail: [] }, + ]); } next = await generation.next(); } - if (aliveRef.current) setPhase({ kind: "result", result: next.value }); + if (controller.signal.aborted) return; + setTasks((current) => finishTasks(current, "done")); + setPhase({ kind: "result", result: next.value }); } catch (error) { - if (aliveRef.current) setPhase({ kind: "error", message: (error as Error).message }); + if (controller.signal.aborted) return; + setTasks((current) => finishTasks(current, "failed")); + setPhase({ kind: "error", message: (error as Error).message }); + } finally { + if (runRef.current?.controller === controller) runRef.current = null; } }; useInput( (input, key) => { if (key.escape) { - navigate(-1); + cancel(); + navigate(PICKER_PATH); return; } if (phase.kind !== "result") return; @@ -116,7 +141,7 @@ function GeneratePolicyForm({ ctx, core, gatewayId }: ScreenProps & { gatewayId: { key: "ctl+c", label: "quit" }, ] : [ - { key: "esc", label: "back" }, + { key: "esc", label: phase.kind === "running" ? "cancel" : "back" }, { key: "ctl+c", label: "quit" }, ]; @@ -152,15 +177,15 @@ function GeneratePolicyForm({ ctx, core, gatewayId }: ScreenProps & { gatewayId: submitDisabled={prompt.trim().length === 0} /> ) : phase.kind === "running" ? ( + + ) : phase.kind === "error" ? ( - - + + setPhase({ kind: "form" })} /> - ) : phase.kind === "error" ? ( - setPhase({ kind: "form" })} /> ) : ( - + {phase.result.policies diff --git a/src/handlers/gateway/policy/types.tsx b/src/handlers/gateway/policy/types.tsx index dabd678fc..13cfecbdc 100644 --- a/src/handlers/gateway/policy/types.tsx +++ b/src/handlers/gateway/policy/types.tsx @@ -27,5 +27,6 @@ export interface CorePolicyClient { generatePolicy( input: GeneratePolicyInput, options: CoreOptions, + signal?: AbortSignal, ): AsyncGenerator; } diff --git a/src/handlers/project/create/screen.tsx b/src/handlers/project/create/screen.tsx index fc4c3d498..55048312a 100644 --- a/src/handlers/project/create/screen.tsx +++ b/src/handlers/project/create/screen.tsx @@ -14,7 +14,6 @@ import { } from "../shortcuts"; import { resolveScaffoldHarnessInput } from "./index"; import { Layout } from "../../../components/Layout"; -import { EventLog } from "../../../components/EventLog"; import { FormTextInput } from "../../../components/FormTextInput"; import { FormRadioGroup, type FormRadioOption } from "../../../components/FormRadioGroup"; import { KeyValueTable } from "../../../components/KeyValueTable"; @@ -783,6 +782,18 @@ function ReviewStep({ // ─── result panels ──────────────────────────────────────────────────────────── +function EventLog({ events }: { events: string[] }) { + return ( + + {events.map((message, index) => ( + + ✓ {message} + + ))} + + ); +} + function SuccessPanel({ name, onContinue }: { name: string; onContinue: () => void }) { useInput((_input, key) => { if (key.return || key.escape) onContinue(); diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 95db03170..43f81ffa8 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -2413,13 +2413,24 @@ export class TestPolicyClient implements CorePolicyClient { ], }; error: Error | undefined; + // hang keeps the generator waiting after its first step until the signal aborts. + hang = false; readonly calls: GeneratePolicyInput[] = []; + readonly signals: (AbortSignal | undefined)[] = []; async *generatePolicy( input: GeneratePolicyInput, + _options: CoreOptions, + signal?: AbortSignal, ): AsyncGenerator { this.calls.push(input); + this.signals.push(signal); yield { type: "step", message: "Resolving gateway" }; + if (this.hang) { + await new Promise((_, reject) => + signal?.addEventListener("abort", () => reject(signal.reason), { once: true }), + ); + } if (this.error) throw this.error; return this.result; }