From e1c787b6be97aebec501e243ade93efc62207b62 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 24 Sep 2026 21:47:40 +0000 Subject: [PATCH 1/9] fix(tui): guard imperative command routes before mounting --- src/components/CommandGate.test.tsx | 365 ++++++++++++++++++ src/components/CommandGate.tsx | 30 ++ src/components/Root.tsx | 184 ++++----- src/handlers/gateway/create/screen.tsx | 5 +- .../runtime/shell/shell.screen.test.tsx | 8 +- 5 files changed, 476 insertions(+), 116 deletions(-) create mode 100644 src/components/CommandGate.test.tsx create mode 100644 src/components/CommandGate.tsx diff --git a/src/components/CommandGate.test.tsx b/src/components/CommandGate.test.tsx new file mode 100644 index 0000000000..76a93209be --- /dev/null +++ b/src/components/CommandGate.test.tsx @@ -0,0 +1,365 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import type { + GetAgentRuntimeResponse, + GetHarnessResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { DEFAULT_GLOBAL_CONFIG } from "../globalConfig"; +import { CommandKey } from "../router"; +import { + cleanupScreens, + compiledRootCommand, + flatFrame, + IMPERATIVE_GLOBAL_CONFIG, + renderScreen, + TestCoreClient, + waitFor, + waitForText, +} from "../testing"; + +afterEach(cleanupScreens); + +class GateTestCore extends TestCoreClient { + readonly paymentCalls = [ + spyOn(this.payment, "getPaymentManager"), + spyOn(this.payment, "listPaymentManagers"), + spyOn(this.payment, "getPaymentConnector"), + spyOn(this.payment, "listPaymentConnectors"), + spyOn(this.payment, "getPaymentSession"), + spyOn(this.payment, "listPaymentSessions"), + spyOn(this.payment, "getPaymentInstrument"), + spyOn(this.payment, "getPaymentInstrumentBalance"), + spyOn(this.payment, "listPaymentInstruments"), + ]; +} + +function expectNoCoreCalls(core: GateTestCore) { + for (const client of [ + core.harness, + core.runtime, + core.memory, + core.gateway, + core.identity, + core.policy, + core.eval, + core.observability, + ]) { + expect(client.calls).toEqual([]); + } + for (const call of core.paymentCalls) expect(call).not.toHaveBeenCalled(); + expect(core.projectCommands).toEqual([]); +} + +const MUTATION_ROUTES = [ + "harness/create", + "harness/update", + "harness/update/update", + "harness/delete", + "harness/delete/delete", + "harness/endpoint/create", + "harness/endpoint/create/update", + "harness/endpoint/update", + "harness/endpoint/update/update", + "harness/endpoint/update/update/delete", + "harness/endpoint/delete", + "harness/endpoint/delete/delete", + "harness/endpoint/delete/delete/update", + "harness/invoke", + "harness/invoke/update", + "harness/invoke/update/delete", + "harness/exec", + "harness/exec/update", + "harness/exec/update/delete", + "runtime/invoke", + "runtime/invoke/update", + "runtime/invoke/update/delete", + "runtime/shell", + "runtime/shell/update", + "runtime/shell/update/delete", + "gateway/invoke", + "gateway/invoke/update", + "gateway/policy", + "gateway/policy/generate", + "gateway/policy/generate/delete", +]; + +const LEGACY_MUTATION_CONFIG = { + ...IMPERATIVE_GLOBAL_CONFIG, + "imperative-mutation-commands": true, +}; +const CLI_MUTATIONS = [ + ...["gateway", "gateway/target", "gateway/connector", "gateway/rule"].flatMap((group) => + ["create", "update", "delete"] + .filter((verb) => group !== "gateway" || verb !== "create") + .map((verb) => `${group}/${verb}`), + ), + ...["api-key-credential-provider", "oauth2-credential-provider"].flatMap((provider) => + ["create", "update", "delete"].map((verb) => `identity/${provider}/${verb}`), + ), +]; + +describe("disabled imperative routes", () => { + test.each(MUTATION_ROUTES)("%s redirects before mounting its screen", async (path) => { + const core = new GateTestCore(); + const screen = renderScreen(`/agentcore/${path}`, { + core, + globalConfig: DEFAULT_GLOBAL_CONFIG, + }); + + await waitForText(screen.lastFrame, "the platform for production AI agents"); + expect(screen.lastFrame()).toContain("type to choose a command"); + expectNoCoreCalls(core); + }); + + test("the legacy mutation flag cannot override the disabled parent", async () => { + const core = new GateTestCore(); + const screen = renderScreen("/agentcore/harness/create", { + core, + globalConfig: { ...DEFAULT_GLOBAL_CONFIG, "imperative-mutation-commands": true }, + }); + await waitForText(screen.lastFrame, "the platform for production AI agents"); + expectNoCoreCalls(core); + }); +}); + +describe("enabled imperative routes", () => { + test.each([ + ["harness/create", "the name of your harness"], + ["harness/update", "choose a harness to update"], + ["harness/delete", "choose a harness to delete"], + ["harness/endpoint/create", "choose a harness to create an endpoint for"], + ["harness/endpoint/update", "choose the harness the endpoint belongs to"], + ["harness/endpoint/delete", "choose the harness the endpoint belongs to"], + ["harness/invoke", "choose a harness to chat with"], + ["harness/exec", "choose a harness to exec into"], + ["runtime/invoke", "choose a Runtime to invoke"], + ["runtime/shell", "choose a Runtime to open a shell"], + ["gateway/invoke", "choose a Gateway to invoke"], + ["gateway/policy/generate", "choose a Gateway to generate a policy for"], + ])("%s restores its screen", async (path, description) => { + const screen = renderScreen(`/agentcore/${path}`, { + globalConfig: IMPERATIVE_GLOBAL_CONFIG, + }); + await waitForText(screen.lastFrame, description); + expect(flatFrame(screen.lastFrame)).not.toContain("the platform for production AI agents"); + }); +}); + +describe("CLI-only mutation routes", () => { + test.each(CLI_MUTATIONS)("%s exposes no command help or Core calls when off", async (path) => { + const core = new GateTestCore(); + const screen = renderScreen(`/agentcore/${path}`, { + core, + globalConfig: DEFAULT_GLOBAL_CONFIG, + }); + await waitForText(() => screen.frames.join("\n"), "Usage:"); + expect(screen.frames.join("\n")).not.toContain("this command runs from the command line"); + expect(screen.frames.join("\n")).not.toContain(`agentcore ${path.replaceAll("/", " ")} [`); + expectNoCoreCalls(core); + }); + + test.each([...CLI_MUTATIONS, "gateway/create"])( + "%s restores specific help when on", + async (path) => { + const core = new GateTestCore(); + const screen = renderScreen(`/agentcore/${path}`, { + core, + globalConfig: LEGACY_MUTATION_CONFIG, + }); + await waitForText(screen.lastFrame, "this command runs from the command line"); + expect(flatFrame(screen.lastFrame)).toContain( + `agentcore ${path.replaceAll("/", " ")} [options]`, + ); + expectNoCoreCalls(core); + }, + ); + + test.each(["gateway", "runtime", "memory"])( + "%s create keeps project guidance when its command is unavailable", + async (family) => { + const core = new GateTestCore(); + const screen = renderScreen(`/agentcore/${family}/create`, { + core, + globalConfig: DEFAULT_GLOBAL_CONFIG, + }); + await waitForText( + screen.lastFrame, + "are created and managed as part of an AgentCore project", + ); + expect(flatFrame(screen.lastFrame)).not.toContain("this command runs from the command line"); + expectNoCoreCalls(core); + }, + ); + + test.each(["payment/manager/create", "payment/connector/update", "payment/session/delete"])( + "%s is unknown, not an implemented mutation", + async (path) => { + for (const globalConfig of [DEFAULT_GLOBAL_CONFIG, IMPERATIVE_GLOBAL_CONFIG]) { + const core = new GateTestCore(); + const screen = renderScreen(`/agentcore/${path}`, { core, globalConfig }); + await waitForText(() => screen.frames.join("\n"), "Usage:"); + expect(screen.frames.join("\n")).not.toContain("this command runs from the command line"); + expectNoCoreCalls(core); + screen.unmount(); + } + }, + ); +}); + +const READ_ROUTES = [ + ["harness/get/update", "harness", "getHarness"], + ["harness/get/delete/json", "harness", "getHarness"], + ["harness/list", "harness", "listHarnesses"], + ["harness/endpoint/get/update/delete", "harness", "getHarnessEndpoint"], + ["harness/endpoint/list/delete", "harness", "listHarnessEndpoints"], + ["harness/version/get/delete/1", "harness", "getHarnessVersion"], + ["harness/version/list/update", "harness", "listHarnessVersions"], + ["runtime/get/update", "runtime", "getRuntime"], + ["runtime/get/delete/json", "runtime", "getRuntime"], + ["runtime/list", "runtime", "listRuntimes"], + ["runtime/endpoint/get/update/delete", "runtime", "getRuntimeEndpoint"], + ["runtime/endpoint/get/delete/update/json", "runtime", "getRuntimeEndpoint"], + ["runtime/endpoint/list/delete", "runtime", "listRuntimeEndpoints"], + ["runtime/version/get/update/1", "runtime", "getRuntimeVersion"], + ["runtime/version/list/delete", "runtime", "listRuntimeVersions"], + ["memory/get/delete", "memory", "getMemory"], + ["memory/get/update/json", "memory", "getMemory"], + ["memory/list", "memory", "listMemories"], + ["memory/actor/list/delete", "memory", "listActors"], + ["memory/session/list/update/delete", "memory", "listSessions"], + ["memory/event/list/update/delete/update", "memory", "listEvents"], + ["memory/event/get/update/delete/update/delete", "memory", "getEvent"], + ["memory/record/list/update/namespace/delete", "memory", "listMemoryRecords"], + ["memory/record/get/delete/update", "memory", "getMemoryRecord"], + ["gateway/get/delete", "gateway", "getGateway"], + ["gateway/get/update/json", "gateway", "getGateway"], + ["gateway/list", "gateway", "listGateways"], + ["gateway/target/list/delete", "gateway", "listGatewayTargets"], + ["gateway/target/get/update/delete", "gateway", "getGatewayTarget"], + ["gateway/connector/list/update", "gateway", "listGatewayConnectors"], + ["gateway/connector/get/delete/update", "gateway", "getGatewayConnector"], + ["gateway/rule/list/update", "gateway", "listGatewayRules"], + ["gateway/rule/get/update/delete", "gateway", "getGatewayRule"], + ["identity/api-key-credential-provider/list", "identity", "listApiKeyCredentialProviders"], + ["identity/api-key-credential-provider/get/update", "identity", "getApiKeyCredentialProvider"], + [ + "identity/api-key-credential-provider/get/delete/json", + "identity", + "getApiKeyCredentialProvider", + ], + ["identity/oauth2-credential-provider/list", "identity", "listOauth2CredentialProviders"], + ["identity/oauth2-credential-provider/get/delete", "identity", "getOauth2CredentialProvider"], + [ + "identity/oauth2-credential-provider/get/update/json", + "identity", + "getOauth2CredentialProvider", + ], +] as const; + +describe("read-only routes remain accessible with the parent off", () => { + test.each(READ_ROUTES)("%s still reads its resource", async (path, family, method) => { + const core = new TestCoreClient(); + const screen = renderScreen(`/agentcore/${path}`, { + core, + globalConfig: DEFAULT_GLOBAL_CONFIG, + }); + await waitFor(() => core[family].calls.some((call) => call.method === method)); + await waitFor(() => !flatFrame(screen.lastFrame).toLowerCase().includes("loading")); + expect(flatFrame(screen.lastFrame)).not.toContain("the platform for production AI agents"); + expect(core[family].calls.every((call) => /^(get|list)/.test(call.method))).toBe(true); + }); +}); + +function projectLaunch(core: TestCoreClient, family: "harness" | "runtime") { + const root = compiledRootCommand(core, DEFAULT_GLOBAL_CONFIG); + const invoke = root.commands.find((command) => command.name() === "invoke")!; + return invoke.commands.find((command) => command.name() === family)!; +} + +describe("public project invoke exception", () => { + test.each( + [[], ["invoke"], ["status"], ["create"]].flatMap((segments) => + ["harness/invoke/update", "runtime/invoke/update/DEFAULT"].map( + (path) => [segments, path] as const, + ), + ), + )("launch path %j does not authorize %s", async (segments, path) => { + const core = new GateTestCore(); + const root = compiledRootCommand(core, DEFAULT_GLOBAL_CONFIG); + const launch = segments.reduce( + (command, segment) => command.commands.find((child) => child.name() === segment)!, + root, + ); + const screen = renderScreen(`/agentcore/${path}`, { + core, + globalConfig: DEFAULT_GLOBAL_CONFIG, + withContext: (ctx) => ctx.withValue(CommandKey, launch), + }); + await waitForText(screen.lastFrame, "the platform for production AI agents"); + expectNoCoreCalls(core); + }); + + test("public project creation still opens without imperative commands", async () => { + const core = new GateTestCore(); + const screen = renderScreen("/agentcore/create", { + core, + globalConfig: DEFAULT_GLOBAL_CONFIG, + }); + await waitForText(screen.lastFrame, "name your project"); + expectNoCoreCalls(core); + }); + + test.each(["harness", "runtime"] as const)( + "the actual project invoke %s command can mount and invoke its resource", + async (family) => { + const core = new TestCoreClient(); + core.harness.setGetResponse({ + harness: { + harnessId: "update", + arn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:harness/update", + }, + } as GetHarnessResponse); + core.runtime.setGetResponse({ + agentRuntimeId: "update", + agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/update", + status: "READY", + protocolConfiguration: { serverProtocol: "HTTP" }, + } as GetAgentRuntimeResponse); + const screen = renderScreen( + `/agentcore/${family}/invoke/update${family === "runtime" ? "/DEFAULT" : ""}`, + { + core, + globalConfig: DEFAULT_GLOBAL_CONFIG, + withContext: (ctx) => ctx.withValue(CommandKey, projectLaunch(core, family)), + }, + ); + await waitForText(screen.lastFrame, family === "harness" ? "send a message" : "payload"); + await screen.write(family === "harness" ? "hello" : '{"prompt":"hello"}'); + await screen.press("return"); + await waitFor(() => + core[family].calls.some( + (call) => call.method === (family === "harness" ? "invokeHarness" : "invokeRuntime"), + ), + ); + }, + ); + + test.each([ + ["harness", "harness/invoke"], + ["runtime", "runtime/invoke"], + ["harness", "runtime/invoke/update/DEFAULT"], + ["runtime", "harness/invoke/update"], + ["harness", "harness/exec/update"], + ["runtime", "runtime/shell/update/DEFAULT"], + ["harness", "harness/update/update"], + ["runtime", "gateway/invoke/update"], + ] as const)("project invoke %s does not authorize %s", async (family, path) => { + const core = new GateTestCore(); + const screen = renderScreen(`/agentcore/${path}`, { + core, + globalConfig: DEFAULT_GLOBAL_CONFIG, + withContext: (ctx) => ctx.withValue(CommandKey, projectLaunch(core, family)), + }); + await waitForText(screen.lastFrame, "the platform for production AI agents"); + expectNoCoreCalls(core); + }); +}); diff --git a/src/components/CommandGate.tsx b/src/components/CommandGate.tsx new file mode 100644 index 0000000000..e64dd32ec4 --- /dev/null +++ b/src/components/CommandGate.tsx @@ -0,0 +1,30 @@ +import { Navigate, Outlet, useLocation, useResolvedPath } from "react-router"; +import { CommandKey, type Context } from "../router"; +import { commandPath, resolveCommand } from "./RouterScreen"; + +export function isCommandAvailable(ctx: Context, path: string[]): boolean { + const command = resolveCommand(ctx.require(CommandKey), path); + return commandPath(command).join("/") === path.join("/"); +} + +// Mount at the command's route, above its index and resource-ID routes, so an +// unavailable command cannot mount a screen that fetches or mutates resources. +export function CommandGate({ ctx }: { ctx: Context }) { + const path = useResolvedPath(".").pathname.split("/").filter(Boolean); + const locationPath = useLocation().pathname.split("/").filter(Boolean); + const launchPath = commandPath(ctx.require(CommandKey)); + // Project invoke resolves a project resource before launching these shared + // consoles. It does not grant access to exec, shell, or other resource actions. + const isProjectInvoke = + path.length === 3 && + locationPath.length > path.length && + path[2] === "invoke" && + (path[1] === "harness" || path[1] === "runtime") && + launchPath.join("/") === `agentcore/invoke/${path[1]}`; + + return isCommandAvailable(ctx, path) || isProjectInvoke ? ( + + ) : ( + + ); +} diff --git a/src/components/Root.tsx b/src/components/Root.tsx index f86b1b93d6..6d3bc47e3f 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -135,6 +135,7 @@ import { HelpScreen, RootScreen } from "../handlers/screen.tsx"; import { RegionKey } from "../handlers/keys.tsx"; import { RegionPinContext } from "../handlers/utils.tsx"; import type { Context } from "../router"; +import { CommandGate } from "./CommandGate"; export interface RootProps { // path is the command path to the executing node (e.g. "/agentcore"). @@ -260,60 +261,39 @@ function RouteTable({ ctx, core }: ScreenProps) { element={} /> } /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - {/* Deep link that resumes an existing runtime session in the chat. */} - } - /> - } /> - } - /> - } - /> + }> + } /> + + }> + } /> + } /> + + }> + } /> + } /> + + }> + } /> + } /> + {/* Deep link that resumes an existing runtime session in the chat. */} + } + /> + + }> + } /> + } /> + } /> + } /> - } - /> - } - /> + }> + } /> + } /> + {/* Bare `endpoint get` (no target) has nothing to show — send the user to the endpoint listing (same idea for `version get`). */} } /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> + }> + } /> + } /> + } + /> + + }> + } /> + } /> + } + /> + } @@ -456,30 +428,22 @@ function RouteTable({ ctx, core }: ScreenProps) { path="agentcore/memory/get/:memoryId/json" element={} /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> + }> + } /> + } /> + } + /> + + }> + } /> + } /> + } + /> + } /> } /> - } - /> - } - /> + }> + } /> + } /> + } @@ -567,14 +527,10 @@ function RouteTable({ ctx, core }: ScreenProps) { path="agentcore/gateway/policy" element={} /> - } - /> - } - /> + }> + } /> + } /> + } /> } /> command.name() === "create")) { + if ( + gateway.name() === "gateway" && + gateway.commands.some((command) => command.name() === "create") + ) { return ; } diff --git a/src/handlers/runtime/shell/shell.screen.test.tsx b/src/handlers/runtime/shell/shell.screen.test.tsx index fce8071780..63cdb54a21 100644 --- a/src/handlers/runtime/shell/shell.screen.test.tsx +++ b/src/handlers/runtime/shell/shell.screen.test.tsx @@ -1,10 +1,12 @@ import { afterEach, describe, expect, test } from "bun:test"; import { renderTuiAt } from "../../../tui"; import { DebugKey, EndpointKey, JsonKey, RegionKey } from "../../keys"; -import { ValueContext } from "../../../router"; +import { CommandKey, ValueContext } from "../../../router"; import type { RuntimeShellSession } from "../types"; import { cleanupScreens, + compiledRootCommand, + IMPERATIVE_GLOBAL_CONFIG, renderImperativeScreen, TestCoreClient, tick, @@ -102,6 +104,7 @@ describe("RuntimeShellScreen", () => { value.runtime.setShellSession(failedSession); const { streams, stdin } = ttyTestIO(); const ctx = ValueContext.EmptyContext() + .withValue(CommandKey, compiledRootCommand(value, IMPERATIVE_GLOBAL_CONFIG)) .withValue(RegionKey, "us-east-1") .withValue(EndpointKey, undefined) .withValue(JsonKey, false) @@ -134,6 +137,7 @@ describe("RuntimeShellScreen", () => { const value = core(); const { streams } = ttyTestIO(); const ctx = ValueContext.EmptyContext() + .withValue(CommandKey, compiledRootCommand(value, IMPERATIVE_GLOBAL_CONFIG)) .withValue(RegionKey, "us-east-1") .withValue(EndpointKey, undefined) .withValue(JsonKey, false) @@ -150,6 +154,7 @@ describe("RuntimeShellScreen", () => { value.runtime.setError(new Error("shell lookup failed")); const { streams } = ttyTestIO(); const ctx = ValueContext.EmptyContext() + .withValue(CommandKey, compiledRootCommand(value, IMPERATIVE_GLOBAL_CONFIG)) .withValue(RegionKey, "us-east-1") .withValue(EndpointKey, undefined) .withValue(JsonKey, false) @@ -164,6 +169,7 @@ describe("RuntimeShellScreen", () => { const value = core(); const { streams, stdin } = ttyTestIO(); const ctx = ValueContext.EmptyContext() + .withValue(CommandKey, compiledRootCommand(value, IMPERATIVE_GLOBAL_CONFIG)) .withValue(RegionKey, "us-east-1") .withValue(EndpointKey, undefined) .withValue(JsonKey, false) From f708fa31f438c50c940f76cb304b192fb127b19c Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 24 Sep 2026 21:48:18 +0000 Subject: [PATCH 2/9] fix(tui): hide unavailable resource actions --- src/handlers/harness/get/screen.tsx | 17 +- .../project/status/status.screen.test.tsx | 163 +++++++++++++++++- src/handlers/runtime/endpoint/get/screen.tsx | 7 +- src/handlers/runtime/get/screen.tsx | 10 +- 4 files changed, 189 insertions(+), 8 deletions(-) diff --git a/src/handlers/harness/get/screen.tsx b/src/handlers/harness/get/screen.tsx index e50378b0bd..b7d6d48bed 100644 --- a/src/handlers/harness/get/screen.tsx +++ b/src/handlers/harness/get/screen.tsx @@ -19,24 +19,33 @@ import { type LinkedResourceNode, } from "../../../components/LinkedResources"; import { ResourceDetailScreen } from "../../../components/ResourceDetailScreen"; +import { isCommandAvailable } from "../../../components/CommandGate"; // The actions offered for a harness, in menu order. Each routes into the // corresponding flow with the harness preselected. -const ACTIONS: { name: string; description: string; to: (id: string) => string }[] = [ +const ACTIONS: { + name: string; + description: string; + to: (id: string) => string; + readOnly?: boolean; +}[] = [ { name: "detail", description: "show the full JSON definition", to: (id) => `/agentcore/harness/get/${id}/json`, + readOnly: true, }, { name: "endpoints", description: "list this harness's endpoints", to: (id) => `/agentcore/harness/endpoint/list/${id}`, + readOnly: true, }, { name: "versions", description: "list this harness's versions", to: (id) => `/agentcore/harness/version/list/${id}`, + readOnly: true, }, { name: "invoke", @@ -311,7 +320,11 @@ export function HarnessGetScreen(props: ScreenProps) { }} actions={ harnessId && harness - ? ACTIONS.map((action) => ({ + ? ACTIONS.filter( + (action) => + action.readOnly || + isCommandAvailable(props.ctx, ["agentcore", "harness", action.name]), + ).map((action) => ({ name: action.name, description: action.description, onSelect: () => navigate(action.to(harnessId)), diff --git a/src/handlers/project/status/status.screen.test.tsx b/src/handlers/project/status/status.screen.test.tsx index 556d5b3f1b..3940581ff0 100644 --- a/src/handlers/project/status/status.screen.test.tsx +++ b/src/handlers/project/status/status.screen.test.tsx @@ -1,9 +1,14 @@ import { afterEach, describe, expect, test } from "bun:test"; import type { + GetAgentRuntimeEndpointResponse, GetAgentRuntimeResponse, + GetGatewayResponse, GetHarnessResponse, GetMemoryOutput, + GetOauth2CredentialProviderResponse, + ListAgentRuntimeEndpointsResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; +import { DEFAULT_GLOBAL_CONFIG, type GlobalConfig } from "../../../globalConfig"; import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; import { ProjectSpecSchema } from "../../../projectSchemas/project"; import { ProjectKey } from "../../../router"; @@ -11,6 +16,7 @@ import { cleanupScreens, flatFrame, inTempDirectory, + IMPERATIVE_GLOBAL_CONFIG, renderScreen, TestCoreClient, waitFor, @@ -102,9 +108,14 @@ function core( return value; } -function renderStatus(value: TestCoreClient, seed: Project = RUNTIME_PROJECT) { +function renderStatus( + value: TestCoreClient, + seed: Project = RUNTIME_PROJECT, + globalConfig: GlobalConfig = DEFAULT_GLOBAL_CONFIG, +) { return renderScreen("/agentcore/status", { core: value, + globalConfig, withContext: (ctx) => ctx.withValue(ProjectKey, seed), }); } @@ -214,6 +225,152 @@ describe("project status screen", () => { await waitForText(screen.lastFrame, "agentcore → harness → get → " + HARNESS_ID); }); + test.each([false, true])( + "Harness actions from project status respect imperative-commands=%s", + async (enabled) => { + const value = core([deployed("harness", "support", { arn: `${ARN}:harness/${HARNESS_ID}` })]); + const screen = renderStatus( + value, + project({ harnesses: [{ name: "support", path: "app/support" }] }), + enabled ? IMPERATIVE_GLOBAL_CONFIG : DEFAULT_GLOBAL_CONFIG, + ); + await waitForGroup(screen, "support"); + await screen.press("down"); + await screen.press("return"); + await waitForText(screen.lastFrame, "show the full JSON definition"); + + const frame = flatFrame(screen.lastFrame); + for (const description of [ + "update this harness", + "chat with this harness", + "run shell commands in this harness", + ]) { + if (enabled) expect(frame).toContain(description); + else expect(frame).not.toContain(description); + } + expect(frame).toContain("list this harness's endpoints"); + expect(frame).toContain("list this harness's versions"); + if (enabled) { + for (let press = 0; press < 5; press++) await screen.press("down"); + expect(focusedLine(screen.lastFrame())).toContain("update"); + await screen.press("return"); + await waitForText(screen.lastFrame, "choose a model"); + expect(flatFrame(screen.lastFrame)).toContain("harness → update"); + } else { + await screen.press("return"); + await waitForText(screen.lastFrame, '"harnessId"'); + } + expect(value.harness.calls.every(({ method }) => method === "getHarness")).toBe(true); + }, + ); + + test("disabled Runtime execution leaves endpoint and JSON navigation available", async () => { + const value = core(); + value.runtime.setListEndpointsResponse({ + runtimeEndpoints: [ + { + name: "delete", + status: "READY", + agentRuntimeEndpointArn: `${ARN}:runtime/${RUNTIME_ID}/runtime-endpoint/delete`, + }, + ], + } as ListAgentRuntimeEndpointsResponse); + value.runtime.setGetEndpointResponse({ + name: "delete", + status: "READY", + liveVersion: "1", + } as GetAgentRuntimeEndpointResponse); + const screen = renderStatus(value); + + await waitForGroup(screen); + await screen.press("down"); + await screen.press("return"); + await waitForText(screen.lastFrame, "READY"); + expect(flatFrame(screen.lastFrame)).not.toContain("invoke this Runtime"); + expect(flatFrame(screen.lastFrame)).not.toContain("open an interactive terminal"); + expect(focusedLine(screen.lastFrame())).toContain("endpoints"); + await screen.press("return"); + await waitForText(screen.lastFrame, "delete"); + await screen.press("return"); + await waitForText(screen.lastFrame, "liveVersion"); + expect(flatFrame(screen.lastFrame)).not.toContain("invoke this Runtime endpoint"); + expect(focusedLine(screen.lastFrame())).toContain("detail"); + await screen.press("return"); + await waitForText(screen.lastFrame, '"liveVersion"'); + expect(value.runtime.calls.every(({ method }) => /^(get|list)/.test(method))).toBe(true); + const call = value.runtime.calls.find(({ method }) => method === "getRuntimeEndpoint")!; + expect(call.args).toEqual([ + RUNTIME_ID, + "delete", + expect.objectContaining({ region: TARGET.region }), + ]); + }); + + test.each(["gateway", "oauth2"])( + "a project Harness's linked %s remains readable with imperative commands off", + async (linked) => { + const value = core([deployed("harness", "support", { arn: `${ARN}:harness/${HARNESS_ID}` })]); + value.harness.setGetResponse({ + harness: { + harnessId: HARNESS_ID, + harnessName: "support", + arn: `${ARN}:harness/${HARNESS_ID}`, + tools: [ + { + type: "agentcore_gateway", + config: { + agentCoreGateway: { + gatewayArn: `${ARN}:gateway/update`, + outboundAuth: { + oauth: { + providerArn: `${ARN}:token-vault/default/oauth2credentialprovider/delete`, + }, + }, + }, + }, + }, + ], + }, + } as GetHarnessResponse); + value.gateway.setGetResponse({ + gatewayId: "update", + name: "linked-gateway", + gatewayArn: `${ARN}:gateway/update`, + status: "READY", + } as GetGatewayResponse); + value.identity.setGetOauth2Response({ + name: "delete", + status: "READY", + } as GetOauth2CredentialProviderResponse); + const screen = renderStatus( + value, + project({ harnesses: [{ name: "support", path: "app/support" }] }), + ); + await waitForGroup(screen, "support"); + await screen.press("down"); + await screen.press("return"); + await waitForText(screen.lastFrame, "linked resources"); + for (let press = 0; press < (linked === "gateway" ? 3 : 4); press++) { + await screen.press("down"); + } + expect(focusedLine(screen.lastFrame())).toContain(linked); + await screen.press("return"); + await waitForText(screen.lastFrame, "READY"); + if (linked === "gateway") { + const call = value.gateway.calls.find(({ method }) => method === "getGateway")!; + expect(call.args).toEqual(["update", expect.objectContaining({ region: TARGET.region })]); + expect(flatFrame(screen.lastFrame)).toContain("browse every Target"); + } else { + const call = value.identity.calls.find( + ({ method }) => method === "getOauth2CredentialProvider", + )!; + expect(call.args).toEqual(["delete", expect.objectContaining({ region: TARGET.region })]); + expect(flatFrame(screen.lastFrame)).toContain("show the full JSON definition"); + } + expect(value.harness.calls.every(({ method }) => method === "getHarness")).toBe(true); + }, + ); + test("escape from a detail page returns to the status screen", async () => { const screen = renderStatus(core()); @@ -290,9 +447,7 @@ describe("project status screen", () => { await screen.press("down"); await screen.press("return"); await waitForText(screen.lastFrame, "READY"); - // invoke → shell → endpoints. - await screen.press("down"); - await screen.press("down"); + // With imperative commands off, endpoints is the first action. await screen.press("return"); await waitForText(screen.lastFrame, "agentcore → runtime → endpoint → list → " + RUNTIME_ID); diff --git a/src/handlers/runtime/endpoint/get/screen.tsx b/src/handlers/runtime/endpoint/get/screen.tsx index eec3e0ad29..be845da4cf 100644 --- a/src/handlers/runtime/endpoint/get/screen.tsx +++ b/src/handlers/runtime/endpoint/get/screen.tsx @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { useNavigate, useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; import { ResourceDetailScreen } from "../../../../components/ResourceDetailScreen"; +import { isCommandAvailable } from "../../../../components/CommandGate"; import type { ScreenProps } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; @@ -61,7 +62,11 @@ export function RuntimeGetEndpointScreen(props: ScreenProps) { description: "show the full JSON definition", onSelect: () => navigate(endpointPath(runtimeId, qualifier, "json")), }, - ] + ].filter( + (action) => + action.name === "detail" || + isCommandAvailable(props.ctx, ["agentcore", "runtime", action.name]), + ) : [] } loadingLabel={`loading endpoint ${qualifier ?? ""} for Runtime ${runtimeId ?? ""}…`} diff --git a/src/handlers/runtime/get/screen.tsx b/src/handlers/runtime/get/screen.tsx index bbf4747040..f18ecf95bf 100644 --- a/src/handlers/runtime/get/screen.tsx +++ b/src/handlers/runtime/get/screen.tsx @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { useNavigate, useParams } from "react-router"; import { JsonDetail } from "../../../components/JsonDetail"; import { ResourceDetailScreen } from "../../../components/ResourceDetailScreen"; +import { isCommandAvailable } from "../../../components/CommandGate"; import type { ScreenProps } from "../../types"; import { coreOptsFromCtx } from "../../utils"; @@ -22,16 +23,19 @@ const ACTIONS = [ name: "endpoints", description: "list this Runtime's endpoints", to: (id: string) => `/agentcore/runtime/endpoint/list/${encodeURIComponent(id)}`, + readOnly: true, }, { name: "versions", description: "list immutable Runtime versions", to: (id: string) => `/agentcore/runtime/version/list/${encodeURIComponent(id)}`, + readOnly: true, }, { name: "detail", description: "show the full JSON definition", to: (id: string) => `/agentcore/runtime/get/${encodeURIComponent(id)}/json`, + readOnly: true, }, ] as const; @@ -65,7 +69,11 @@ export function RuntimeGetScreen(props: ScreenProps) { }} actions={ runtimeId && detail.data - ? ACTIONS.map((action) => ({ + ? ACTIONS.filter( + (action) => + ("readOnly" in action && action.readOnly) || + isCommandAvailable(props.ctx, ["agentcore", "runtime", action.name]), + ).map((action) => ({ name: action.name, description: action.description, onSelect: () => From a20c888b876ab5164c098c1e1f9915473f6eb52e Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 24 Sep 2026 21:48:57 +0000 Subject: [PATCH 3/9] fix(invoke): keep disabled imperative actions out of project consoles --- .../harness/invoke/invoke.screen.test.tsx | 38 +++ src/handlers/harness/invoke/screen.tsx | 9 +- .../invoke/project-target.screen.test.tsx | 241 ++++++++++++++++++ src/handlers/runtime/invoke/screen.tsx | 14 +- 4 files changed, 297 insertions(+), 5 deletions(-) create mode 100644 src/handlers/runtime/invoke/project-target.screen.test.tsx diff --git a/src/handlers/harness/invoke/invoke.screen.test.tsx b/src/handlers/harness/invoke/invoke.screen.test.tsx index e3714cc435..a4c288ecd5 100644 --- a/src/handlers/harness/invoke/invoke.screen.test.tsx +++ b/src/handlers/harness/invoke/invoke.screen.test.tsx @@ -5,6 +5,8 @@ import type { } from "@aws-sdk/client-bedrock-agentcore"; import type { GetHarnessResponse, HarnessSummary } from "@aws-sdk/client-bedrock-agentcore-control"; import { + compiledRootCommand, + renderScreen, renderImperativeScreen, waitForText, waitFor, @@ -12,6 +14,8 @@ import { StreamController, TestCoreClient, } from "../../../testing"; +import { DEFAULT_GLOBAL_CONFIG } from "../../../globalConfig"; +import { CommandKey } from "../../../router"; afterEach(cleanupScreens); @@ -62,6 +66,40 @@ async function sendMessage(r: ReturnType, text: s await r.press("return"); } +describe("project invocation with imperative commands disabled", () => { + test.each([false, true])("exec access follows the imperative flag %s", async (enabled) => { + const core = chatCore(); + const globalConfig = { ...DEFAULT_GLOBAL_CONFIG, "imperative-commands": enabled }; + const command = compiledRootCommand(core, globalConfig) + .commands.find((child) => child.name() === "invoke")! + .commands.find((child) => child.name() === "harness")!; + const r = renderScreen(CHAT_PATH, { + core, + globalConfig, + withContext: (ctx) => ctx.withValue(CommandKey, command), + }); + + await waitForText(r.lastFrame, "send a message"); + expect(r.lastFrame()!.includes("ctrl+e")).toBe(enabled); + await r.write("\u0005"); + if (enabled) { + await waitForText(r.lastFrame, "run a command"); + await sendMessage(r, "pwd"); + await waitFor(() => + core.harness.calls.some((call) => call.method === "invokeAgentRuntimeCommand"), + ); + } else { + expect(r.lastFrame()).not.toContain("run a command"); + await sendMessage(r, "hello"); + await waitForText(r.lastFrame, "Hello from the agent"); + expect(core.harness.calls.some((call) => call.method === "invokeHarness")).toBe(true); + expect(core.harness.calls.some((call) => call.method === "invokeAgentRuntimeCommand")).toBe( + false, + ); + } + }); +}); + describe("invoke picker screen", () => { test("lists harnesses with a chat-flavored subtitle", async () => { const core = new TestCoreClient(); diff --git a/src/handlers/harness/invoke/screen.tsx b/src/handlers/harness/invoke/screen.tsx index 838e85668a..0badce2d85 100644 --- a/src/handlers/harness/invoke/screen.tsx +++ b/src/handlers/harness/invoke/screen.tsx @@ -8,6 +8,7 @@ import { coreOptsFromCtx } from "../../utils"; import { HarnessPicker } from "../../../components/HarnessPicker"; import { HarnessEndpointPicker } from "../../../components/HarnessEndpointPicker"; import { Layout } from "../../../components/Layout"; +import { isCommandAvailable } from "../../../components/CommandGate"; import { Divider } from "../../../components/ui/divider"; import { Markdown } from "../../../components/ui/markdown"; import { Spinner } from "../../../components/ui/spinner"; @@ -96,6 +97,7 @@ export function HarnessChat({ onBack, }: HarnessChatProps) { const opts = coreOptsFromCtx(ctx); + const canExec = isCommandAvailable(ctx, ["agentcore", "harness", "exec"]); const { columns, rows } = useWindowSize(); const navigate = useNavigate(); @@ -203,6 +205,7 @@ export function HarnessChat({ // runExec runs a shell command in the chat session's container (exec mode) // and folds the streamed stdout/stderr into an exec transcript item. const runExec = async (text: string) => { + if (!canExec) return; const command = text.trim(); const arn = detail.data?.harness?.arn; if (command === "" || streamingRef.current || !arn) return; @@ -271,7 +274,7 @@ export function HarnessChat({ useInput( (input, key) => { if (key.ctrl && input === "e") { - toggleMode(); + if (canExec) toggleMode(); return; } if (key.ctrl && input === "t") { @@ -330,7 +333,9 @@ export function HarnessChat({ ] : [ { key: "enter", label: mode === "exec" ? "run" : "send" }, - { key: "ctrl+e", label: mode === "exec" ? "chat mode" : "exec mode" }, + ...(canExec + ? [{ key: "ctrl+e", label: mode === "exec" ? "chat mode" : "exec mode" }] + : []), { key: "ctrl+t", label: "endpoint" }, { key: "↑↓", label: "scroll" }, { key: "esc", label: "back" }, diff --git a/src/handlers/runtime/invoke/project-target.screen.test.tsx b/src/handlers/runtime/invoke/project-target.screen.test.tsx new file mode 100644 index 0000000000..5d84e2a5d6 --- /dev/null +++ b/src/handlers/runtime/invoke/project-target.screen.test.tsx @@ -0,0 +1,241 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { + AgentRuntimeEndpoint, + GetAgentRuntimeResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { DEFAULT_GLOBAL_CONFIG } from "../../../globalConfig"; +import { ProjectSpecSchema } from "../../../projectSchemas/project"; +import { CommandKey, ProjectKey } from "../../../router"; +import { + cleanupScreens, + compiledRootCommand, + flatFrame, + renderScreen, + TestCoreClient, + waitFor, + waitForText, +} from "../../../testing"; +import type { Project } from "../../project/types"; +import type { RuntimeInvokeRequest } from "../types"; + +afterEach(cleanupScreens); + +const PROJECT_RUNTIME = "project-runtime"; +const OTHER_RUNTIME = "standalone-other"; +const TARGET = { name: "default", account: "123456789012", region: "us-east-1" } as const; +const TARGET_CREDENTIALS = async () => ({ + accessKeyId: "test-access-key", + secretAccessKey: "test-secret-key", +}); +const PROJECT: Project = { + name: "orders", + rootPath: "/tmp/orders", + spec: ProjectSpecSchema.parse({ + name: "orders", + version: 2, + runtimes: [ + { + name: "checkout", + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app/checkout", + runtimeVersion: "PYTHON_3_14", + }, + ], + }), +}; + +type Launch = "CLI context" | "project picker"; + +class ProjectTargetCore extends TestCoreClient { + constructor() { + super(); + this.projectManager.listTargets = async () => [TARGET]; + this.projectManager.resolveDeployedResources = async () => ({ + target: TARGET, + resources: [ + { + resourceType: "runtime", + name: "checkout", + id: PROJECT_RUNTIME, + target: TARGET, + credentialProvider: TARGET_CREDENTIALS, + }, + ], + }); + this.runtime.setListResponse({ + agentRuntimes: [ + { + agentRuntimeId: OTHER_RUNTIME, + agentRuntimeArn: `arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/${OTHER_RUNTIME}`, + agentRuntimeVersion: "1", + agentRuntimeName: "standalone", + description: "Runtime outside the project", + lastUpdatedAt: new Date(0), + status: "READY", + }, + ], + }); + this.selectRuntime(PROJECT_RUNTIME); + } + + selectRuntime(id: string) { + const arn = `arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/${id}`; + this.runtime.setGetResponse({ + agentRuntimeId: id, + agentRuntimeArn: arn, + status: "READY", + protocolConfiguration: { serverProtocol: "HTTP" }, + } as GetAgentRuntimeResponse); + this.runtime.setListEndpointsResponse({ + runtimeEndpoints: ["DEFAULT", "canary"].map((name): AgentRuntimeEndpoint => ({ + id: name, + name, + agentRuntimeArn: arn, + agentRuntimeEndpointArn: `${arn}/runtime-endpoint/${name}`, + liveVersion: "1", + targetVersion: "1", + status: "READY", + createdAt: new Date(0), + lastUpdatedAt: new Date(0), + })), + }); + } + + requests(): RuntimeInvokeRequest[] { + return this.runtime.calls + .filter((call) => call.method === "invokeRuntime") + .map((call) => call.args[0] as RuntimeInvokeRequest); + } + + renderCli(enabled: boolean, qualifier?: string) { + const globalConfig = { ...DEFAULT_GLOBAL_CONFIG, "imperative-commands": enabled }; + const launch = compiledRootCommand(this, globalConfig) + .commands.find((command) => command.name() === "invoke")! + .commands.find((command) => command.name() === "runtime")!; + return renderScreen( + `/agentcore/runtime/invoke/${PROJECT_RUNTIME}${qualifier ? `/${qualifier}` : ""}`, + { + core: this, + globalConfig, + withContext: (ctx) => ctx.withValue(CommandKey, launch).withValue(ProjectKey, PROJECT), + }, + ); + } + + async openConsole(launch: Launch, enabled: boolean) { + if (launch === "CLI context") { + const screen = this.renderCli(enabled, "DEFAULT"); + await waitForText(screen.lastFrame, "Enter JSON payload"); + return screen; + } + const screen = renderScreen("/agentcore/invoke", { + core: this, + globalConfig: { ...DEFAULT_GLOBAL_CONFIG, "imperative-commands": enabled }, + withContext: (ctx) => ctx.withValue(ProjectKey, PROJECT), + }); + await waitForText(screen.lastFrame, "checkout"); + await screen.press("return"); + await waitForText(screen.lastFrame, "DEFAULT"); + await screen.press("return"); + await waitForText(screen.lastFrame, "Enter JSON payload"); + return screen; + } +} + +describe("project Runtime target selection", () => { + test.each(["CLI context", "project picker"] as const)( + "%s with imperative commands off keeps target selection scoped to the project Runtime", + async (launch) => { + const core = new ProjectTargetCore(); + const screen = await core.openConsole(launch, false); + + await screen.write("\x14"); + await waitForText(screen.lastFrame, "choose another endpoint"); + expect(flatFrame(screen.lastFrame)).toContain(PROJECT_RUNTIME); + expect(screen.lastFrame()).not.toContain("choose another Runtime"); + expect(core.runtime.calls.some((call) => call.method === "listRuntimes")).toBe(false); + + await screen.press("escape"); + await waitForText(screen.lastFrame, "Enter JSON payload"); + await screen.write('{"turn":1}'); + await screen.press("return"); + await waitFor(() => core.requests().length === 1); + await waitForText(screen.lastFrame, "complete"); + expect(core.requests()[0]).toMatchObject({ + runtimeId: PROJECT_RUNTIME, + qualifier: "DEFAULT", + }); + + await screen.write("\x14"); + await waitForText(screen.lastFrame, "canary"); + await screen.press("down"); + await screen.press("return"); + await waitForText(screen.lastFrame, "Enter JSON payload"); + await screen.write('{"turn":2}'); + await screen.press("return"); + await waitFor(() => core.requests().length === 2); + expect(core.requests()[1]).toMatchObject({ + runtimeId: PROJECT_RUNTIME, + qualifier: "canary", + }); + expect(core.runtime.calls.some((call) => call.method === "listRuntimes")).toBe(false); + expect( + core.runtime.calls + .filter((call) => call.method === "listRuntimeEndpoints") + .every((call) => call.args[0] === PROJECT_RUNTIME), + ).toBe(true); + }, + ); + + test.each(["CLI context", "project picker"] as const)( + "%s with imperative commands on can select and invoke another Runtime", + async (launch) => { + const core = new ProjectTargetCore(); + const screen = await core.openConsole(launch, true); + + await screen.write("\x14"); + await waitForText(screen.lastFrame, "choose another Runtime"); + await waitForText(screen.lastFrame, OTHER_RUNTIME); + expect(core.runtime.calls.some((call) => call.method === "listRuntimes")).toBe(true); + core.selectRuntime(OTHER_RUNTIME); + await screen.press("return"); + await waitForText(screen.lastFrame, "choose another endpoint"); + await waitForText(screen.lastFrame, "DEFAULT"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "choose another Runtime"); + await waitForText(screen.lastFrame, OTHER_RUNTIME); + await screen.press("return"); + await waitForText(screen.lastFrame, "choose another endpoint"); + await waitForText(screen.lastFrame, "DEFAULT"); + await screen.press("return"); + await waitForText(screen.lastFrame, "Enter JSON payload"); + await screen.write('{"turn":1}'); + await screen.press("return"); + await waitFor(() => core.requests().length === 1); + expect(core.requests()[0]).toMatchObject({ + runtimeId: OTHER_RUNTIME, + qualifier: "DEFAULT", + }); + }, + ); + + test.each([false, true])( + "initial CLI endpoint picker Escape respects imperative-commands=%s", + async (enabled) => { + const core = new ProjectTargetCore(); + const screen = core.renderCli(enabled); + await waitForText(screen.lastFrame, "choose an endpoint to invoke"); + await waitForText(screen.lastFrame, "DEFAULT"); + + await screen.press("escape"); + await waitForText( + screen.lastFrame, + enabled ? "choose a Runtime to invoke" : "the platform for production AI agents", + ); + if (enabled) await waitForText(screen.lastFrame, OTHER_RUNTIME); + expect(core.runtime.calls.some((call) => call.method === "listRuntimes")).toBe(enabled); + expect(core.requests()).toEqual([]); + }, + ); +}); diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index 625edd52ac..9e145e239f 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -9,6 +9,7 @@ import cliTruncate from "cli-truncate"; import type { ScreenProps } from "../../types"; import { coreOptsFromCtx } from "../../utils"; import { Layout } from "../../../components/Layout"; +import { isCommandAvailable } from "../../../components/CommandGate"; import { MultilineInput } from "../../../components/MultilineInput"; import { RuntimeEndpointPicker } from "../../../components/RuntimeEndpointPicker"; import { RuntimePicker } from "../../../components/RuntimePicker"; @@ -158,6 +159,7 @@ export function RuntimeInvokeConsole({ onBack, }: RuntimeInvokeConsoleProps) { const opts = coreOptsFromCtx(ctx); + const canSelectRuntime = isCommandAvailable(ctx, ["agentcore", "runtime", "invoke"]); const navigate = useNavigate(); const { columns, rows } = useWindowSize(); const [target, setTarget] = useState({ runtimeId, qualifier }); @@ -304,7 +306,13 @@ export function RuntimeInvokeConsole({ (input, key) => { if (key.ctrl) { if (input === "v" && !abortRef.current) setPrettyJson((current) => !current); - else if (input === "t" && !abortRef.current) setTargetPicker({ stage: "runtime" }); + else if (input === "t" && !abortRef.current) { + setTargetPicker( + canSelectRuntime + ? { stage: "runtime" } + : { stage: "endpoint", runtimeId: target.runtimeId }, + ); + } return; } if (key.escape) { @@ -371,7 +379,7 @@ export function RuntimeInvokeConsole({ } setTargetPicker(null); }} - onEscape={() => setTargetPicker({ stage: "runtime" })} + onEscape={() => setTargetPicker(canSelectRuntime ? { stage: "runtime" } : null)} /> ); } @@ -396,7 +404,7 @@ export function RuntimeInvokeConsole({ }, ] : [{ key: `${glyphs.shift}${glyphs.enter}`, label: "newline" }]), - { key: "ctrl+t", label: "target" }, + { key: "ctrl+t", label: canSelectRuntime ? "target" : "endpoint" }, { key: "↑↓", label: "scroll" }, { key: "esc", label: "back" }, { key: "ctrl+c", label: "quit" }, From 88fc3e4e711bafb6c2acb43a9e69526765350d24 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 24 Sep 2026 21:49:37 +0000 Subject: [PATCH 4/9] test(project): cover CLI to TUI invocation with imperative commands off --- .../project/invoke/invoke.screen.test.tsx | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/handlers/project/invoke/invoke.screen.test.tsx b/src/handlers/project/invoke/invoke.screen.test.tsx index f93760d815..66965c652e 100644 --- a/src/handlers/project/invoke/invoke.screen.test.tsx +++ b/src/handlers/project/invoke/invoke.screen.test.tsx @@ -7,12 +7,19 @@ import type { import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; import { ProjectSpecSchema } from "../../../projectSchemas/project"; import { ProjectKey } from "../../../router"; +import { DEFAULT_GLOBAL_CONFIG } from "../../../globalConfig"; +import { createRootHandler } from "../../index"; import { cleanupScreens, + createSilentLogger, flatFrame, inTempDirectory, renderScreen, TestCoreClient, + TestGlobalConfigAccessor, + tick, + ttyTestIO, + waitFor, waitForFlatText, waitForText, } from "../../../testing"; @@ -111,6 +118,52 @@ function core( } describe("project invoke picker", () => { + test.each(["harness", "runtime"] as const)( + "the public %s CLI command opens and invokes through the TUI with the flag off", + async (family) => { + const value = core(); + value.projectManager.resolve = async () => project; + const { streams, stdin } = ttyTestIO(); + const root = createRootHandler(value, { + io: streams.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor({ + initialConfigData: DEFAULT_GLOBAL_CONFIG, + }), + globalConfig: DEFAULT_GLOBAL_CONFIG, + }); + const rendering = root.route([ + "node", + "agentcore", + "invoke", + family, + "--name", + family === "runtime" ? "checkout" : "support", + "--qualifier", + "DEFAULT", + "--region", + TARGET.region, + ]); + try { + await waitFor(() => + streams.stdout().includes(family === "harness" ? "send a message" : "Enter JSON payload"), + ); + expect(streams.stdout()).not.toContain("exec mode"); + stdin.write(family === "harness" ? "hello" : '{"prompt":"hello"}'); + await tick(); + stdin.write("\r"); + const method = family === "harness" ? "invokeHarness" : "invokeRuntime"; + await waitFor(() => value[family].calls.some((call) => call.method === method)); + expect( + value.harness.calls.some((call) => call.method === "invokeAgentRuntimeCommand"), + ).toBe(false); + } finally { + stdin.write("\x03"); + await rendering; + } + }, + ); + test("lists only resources present in the deployed target", async () => { const screen = renderScreen("/agentcore/invoke", { core: core([ From dd6d4351d740d331d2bca4780ad4ea4c37acf171 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 24 Sep 2026 21:50:30 +0000 Subject: [PATCH 5/9] test(runtime): reject disabled shell handoff before opening a session --- .../runtime/shell/shell.screen.test.tsx | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/handlers/runtime/shell/shell.screen.test.tsx b/src/handlers/runtime/shell/shell.screen.test.tsx index 63cdb54a21..631f0fd7e0 100644 --- a/src/handlers/runtime/shell/shell.screen.test.tsx +++ b/src/handlers/runtime/shell/shell.screen.test.tsx @@ -81,6 +81,30 @@ async function interruptUntilExit(rendering: Promise, stdin: TtyInput): Pr } describe("RuntimeShellScreen", () => { + test("renderTuiAt blocks a direct shell before opening a session when disabled", async () => { + const value = core(); + const { streams, stdin } = ttyTestIO(); + const ctx = ValueContext.EmptyContext() + .withValue(CommandKey, compiledRootCommand(value)) + .withValue(RegionKey, "us-east-1") + .withValue(EndpointKey, undefined) + .withValue(JsonKey, false) + .withValue(DebugKey, false); + const rendering = renderTuiAt( + "/agentcore/runtime/shell/checkout-AbCdEf1234/prod", + ctx, + value, + streams.io, + ); + try { + await waitFor(() => streams.stdout().includes("the platform for production AI agents")); + expect(value.runtime.calls).toEqual([]); + expect(streams.stderr()).not.toContain("Connected"); + } finally { + await interruptUntilExit(rendering, stdin); + } + }); + test("a direct Runtime route skips the Runtime picker", async () => { const screen = renderImperativeScreen("/agentcore/runtime/shell/checkout-AbCdEf1234", { core: core(), From d195d7b65c9b04d48c93c63d5e45b3ddcbf06adf Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 24 Sep 2026 22:00:42 +0000 Subject: [PATCH 6/9] test(tui): reuse legacy config fixture for parent gate coverage --- src/components/CommandGate.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/CommandGate.test.tsx b/src/components/CommandGate.test.tsx index 76a93209be..ccef97d4b9 100644 --- a/src/components/CommandGate.test.tsx +++ b/src/components/CommandGate.test.tsx @@ -114,7 +114,7 @@ describe("disabled imperative routes", () => { const core = new GateTestCore(); const screen = renderScreen("/agentcore/harness/create", { core, - globalConfig: { ...DEFAULT_GLOBAL_CONFIG, "imperative-mutation-commands": true }, + globalConfig: { ...LEGACY_MUTATION_CONFIG, "imperative-commands": false }, }); await waitForText(screen.lastFrame, "the platform for production AI agents"); expectNoCoreCalls(core); From 821683a7fb7cdcea2bdcfcdf581aeb4af939bc79 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 24 Sep 2026 23:44:30 +0000 Subject: [PATCH 7/9] test(tui): focus command gate coverage on disabled routes --- src/components/CommandGate.test.tsx | 305 ++-------------------------- 1 file changed, 22 insertions(+), 283 deletions(-) diff --git a/src/components/CommandGate.test.tsx b/src/components/CommandGate.test.tsx index ccef97d4b9..47673d7e2b 100644 --- a/src/components/CommandGate.test.tsx +++ b/src/components/CommandGate.test.tsx @@ -1,52 +1,23 @@ -import { afterEach, describe, expect, spyOn, test } from "bun:test"; -import type { - GetAgentRuntimeResponse, - GetHarnessResponse, -} from "@aws-sdk/client-bedrock-agentcore-control"; +import { afterEach, describe, expect, test } from "bun:test"; import { DEFAULT_GLOBAL_CONFIG } from "../globalConfig"; import { CommandKey } from "../router"; import { cleanupScreens, compiledRootCommand, - flatFrame, - IMPERATIVE_GLOBAL_CONFIG, renderScreen, TestCoreClient, - waitFor, waitForText, } from "../testing"; afterEach(cleanupScreens); -class GateTestCore extends TestCoreClient { - readonly paymentCalls = [ - spyOn(this.payment, "getPaymentManager"), - spyOn(this.payment, "listPaymentManagers"), - spyOn(this.payment, "getPaymentConnector"), - spyOn(this.payment, "listPaymentConnectors"), - spyOn(this.payment, "getPaymentSession"), - spyOn(this.payment, "listPaymentSessions"), - spyOn(this.payment, "getPaymentInstrument"), - spyOn(this.payment, "getPaymentInstrumentBalance"), - spyOn(this.payment, "listPaymentInstruments"), - ]; -} - -function expectNoCoreCalls(core: GateTestCore) { - for (const client of [ - core.harness, - core.runtime, - core.memory, - core.gateway, - core.identity, - core.policy, - core.eval, - core.observability, - ]) { - expect(client.calls).toEqual([]); - } - for (const call of core.paymentCalls) expect(call).not.toHaveBeenCalled(); - expect(core.projectCommands).toEqual([]); +function expectNoResourceCalls(core: TestCoreClient) { + expect([ + ...core.harness.calls, + ...core.runtime.calls, + ...core.gateway.calls, + ...core.policy.calls, + ]).toEqual([]); } const MUTATION_ROUTES = [ @@ -82,24 +53,9 @@ const MUTATION_ROUTES = [ "gateway/policy/generate/delete", ]; -const LEGACY_MUTATION_CONFIG = { - ...IMPERATIVE_GLOBAL_CONFIG, - "imperative-mutation-commands": true, -}; -const CLI_MUTATIONS = [ - ...["gateway", "gateway/target", "gateway/connector", "gateway/rule"].flatMap((group) => - ["create", "update", "delete"] - .filter((verb) => group !== "gateway" || verb !== "create") - .map((verb) => `${group}/${verb}`), - ), - ...["api-key-credential-provider", "oauth2-credential-provider"].flatMap((provider) => - ["create", "update", "delete"].map((verb) => `identity/${provider}/${verb}`), - ), -]; - describe("disabled imperative routes", () => { test.each(MUTATION_ROUTES)("%s redirects before mounting its screen", async (path) => { - const core = new GateTestCore(); + const core = new TestCoreClient(); const screen = renderScreen(`/agentcore/${path}`, { core, globalConfig: DEFAULT_GLOBAL_CONFIG, @@ -107,242 +63,22 @@ describe("disabled imperative routes", () => { await waitForText(screen.lastFrame, "the platform for production AI agents"); expect(screen.lastFrame()).toContain("type to choose a command"); - expectNoCoreCalls(core); - }); - - test("the legacy mutation flag cannot override the disabled parent", async () => { - const core = new GateTestCore(); - const screen = renderScreen("/agentcore/harness/create", { - core, - globalConfig: { ...LEGACY_MUTATION_CONFIG, "imperative-commands": false }, - }); - await waitForText(screen.lastFrame, "the platform for production AI agents"); - expectNoCoreCalls(core); - }); -}); - -describe("enabled imperative routes", () => { - test.each([ - ["harness/create", "the name of your harness"], - ["harness/update", "choose a harness to update"], - ["harness/delete", "choose a harness to delete"], - ["harness/endpoint/create", "choose a harness to create an endpoint for"], - ["harness/endpoint/update", "choose the harness the endpoint belongs to"], - ["harness/endpoint/delete", "choose the harness the endpoint belongs to"], - ["harness/invoke", "choose a harness to chat with"], - ["harness/exec", "choose a harness to exec into"], - ["runtime/invoke", "choose a Runtime to invoke"], - ["runtime/shell", "choose a Runtime to open a shell"], - ["gateway/invoke", "choose a Gateway to invoke"], - ["gateway/policy/generate", "choose a Gateway to generate a policy for"], - ])("%s restores its screen", async (path, description) => { - const screen = renderScreen(`/agentcore/${path}`, { - globalConfig: IMPERATIVE_GLOBAL_CONFIG, - }); - await waitForText(screen.lastFrame, description); - expect(flatFrame(screen.lastFrame)).not.toContain("the platform for production AI agents"); - }); -}); - -describe("CLI-only mutation routes", () => { - test.each(CLI_MUTATIONS)("%s exposes no command help or Core calls when off", async (path) => { - const core = new GateTestCore(); - const screen = renderScreen(`/agentcore/${path}`, { - core, - globalConfig: DEFAULT_GLOBAL_CONFIG, - }); - await waitForText(() => screen.frames.join("\n"), "Usage:"); - expect(screen.frames.join("\n")).not.toContain("this command runs from the command line"); - expect(screen.frames.join("\n")).not.toContain(`agentcore ${path.replaceAll("/", " ")} [`); - expectNoCoreCalls(core); + expectNoResourceCalls(core); }); - test.each([...CLI_MUTATIONS, "gateway/create"])( - "%s restores specific help when on", - async (path) => { - const core = new GateTestCore(); - const screen = renderScreen(`/agentcore/${path}`, { - core, - globalConfig: LEGACY_MUTATION_CONFIG, - }); - await waitForText(screen.lastFrame, "this command runs from the command line"); - expect(flatFrame(screen.lastFrame)).toContain( - `agentcore ${path.replaceAll("/", " ")} [options]`, - ); - expectNoCoreCalls(core); - }, - ); - - test.each(["gateway", "runtime", "memory"])( - "%s create keeps project guidance when its command is unavailable", - async (family) => { - const core = new GateTestCore(); - const screen = renderScreen(`/agentcore/${family}/create`, { - core, - globalConfig: DEFAULT_GLOBAL_CONFIG, - }); - await waitForText( - screen.lastFrame, - "are created and managed as part of an AgentCore project", - ); - expect(flatFrame(screen.lastFrame)).not.toContain("this command runs from the command line"); - expectNoCoreCalls(core); - }, - ); - - test.each(["payment/manager/create", "payment/connector/update", "payment/session/delete"])( - "%s is unknown, not an implemented mutation", - async (path) => { - for (const globalConfig of [DEFAULT_GLOBAL_CONFIG, IMPERATIVE_GLOBAL_CONFIG]) { - const core = new GateTestCore(); - const screen = renderScreen(`/agentcore/${path}`, { core, globalConfig }); - await waitForText(() => screen.frames.join("\n"), "Usage:"); - expect(screen.frames.join("\n")).not.toContain("this command runs from the command line"); - expectNoCoreCalls(core); - screen.unmount(); - } - }, - ); -}); - -const READ_ROUTES = [ - ["harness/get/update", "harness", "getHarness"], - ["harness/get/delete/json", "harness", "getHarness"], - ["harness/list", "harness", "listHarnesses"], - ["harness/endpoint/get/update/delete", "harness", "getHarnessEndpoint"], - ["harness/endpoint/list/delete", "harness", "listHarnessEndpoints"], - ["harness/version/get/delete/1", "harness", "getHarnessVersion"], - ["harness/version/list/update", "harness", "listHarnessVersions"], - ["runtime/get/update", "runtime", "getRuntime"], - ["runtime/get/delete/json", "runtime", "getRuntime"], - ["runtime/list", "runtime", "listRuntimes"], - ["runtime/endpoint/get/update/delete", "runtime", "getRuntimeEndpoint"], - ["runtime/endpoint/get/delete/update/json", "runtime", "getRuntimeEndpoint"], - ["runtime/endpoint/list/delete", "runtime", "listRuntimeEndpoints"], - ["runtime/version/get/update/1", "runtime", "getRuntimeVersion"], - ["runtime/version/list/delete", "runtime", "listRuntimeVersions"], - ["memory/get/delete", "memory", "getMemory"], - ["memory/get/update/json", "memory", "getMemory"], - ["memory/list", "memory", "listMemories"], - ["memory/actor/list/delete", "memory", "listActors"], - ["memory/session/list/update/delete", "memory", "listSessions"], - ["memory/event/list/update/delete/update", "memory", "listEvents"], - ["memory/event/get/update/delete/update/delete", "memory", "getEvent"], - ["memory/record/list/update/namespace/delete", "memory", "listMemoryRecords"], - ["memory/record/get/delete/update", "memory", "getMemoryRecord"], - ["gateway/get/delete", "gateway", "getGateway"], - ["gateway/get/update/json", "gateway", "getGateway"], - ["gateway/list", "gateway", "listGateways"], - ["gateway/target/list/delete", "gateway", "listGatewayTargets"], - ["gateway/target/get/update/delete", "gateway", "getGatewayTarget"], - ["gateway/connector/list/update", "gateway", "listGatewayConnectors"], - ["gateway/connector/get/delete/update", "gateway", "getGatewayConnector"], - ["gateway/rule/list/update", "gateway", "listGatewayRules"], - ["gateway/rule/get/update/delete", "gateway", "getGatewayRule"], - ["identity/api-key-credential-provider/list", "identity", "listApiKeyCredentialProviders"], - ["identity/api-key-credential-provider/get/update", "identity", "getApiKeyCredentialProvider"], - [ - "identity/api-key-credential-provider/get/delete/json", - "identity", - "getApiKeyCredentialProvider", - ], - ["identity/oauth2-credential-provider/list", "identity", "listOauth2CredentialProviders"], - ["identity/oauth2-credential-provider/get/delete", "identity", "getOauth2CredentialProvider"], - [ - "identity/oauth2-credential-provider/get/update/json", - "identity", - "getOauth2CredentialProvider", - ], -] as const; - -describe("read-only routes remain accessible with the parent off", () => { - test.each(READ_ROUTES)("%s still reads its resource", async (path, family, method) => { + test("unavailable Gateway creation shows project guidance, not another command's help", async () => { const core = new TestCoreClient(); - const screen = renderScreen(`/agentcore/${path}`, { + const screen = renderScreen("/agentcore/gateway/create", { core, globalConfig: DEFAULT_GLOBAL_CONFIG, }); - await waitFor(() => core[family].calls.some((call) => call.method === method)); - await waitFor(() => !flatFrame(screen.lastFrame).toLowerCase().includes("loading")); - expect(flatFrame(screen.lastFrame)).not.toContain("the platform for production AI agents"); - expect(core[family].calls.every((call) => /^(get|list)/.test(call.method))).toBe(true); + await waitForText(screen.lastFrame, "Create an AgentCore Gateway"); + expect(screen.lastFrame()).not.toContain("this command runs from the command line"); + expectNoResourceCalls(core); }); }); -function projectLaunch(core: TestCoreClient, family: "harness" | "runtime") { - const root = compiledRootCommand(core, DEFAULT_GLOBAL_CONFIG); - const invoke = root.commands.find((command) => command.name() === "invoke")!; - return invoke.commands.find((command) => command.name() === family)!; -} - -describe("public project invoke exception", () => { - test.each( - [[], ["invoke"], ["status"], ["create"]].flatMap((segments) => - ["harness/invoke/update", "runtime/invoke/update/DEFAULT"].map( - (path) => [segments, path] as const, - ), - ), - )("launch path %j does not authorize %s", async (segments, path) => { - const core = new GateTestCore(); - const root = compiledRootCommand(core, DEFAULT_GLOBAL_CONFIG); - const launch = segments.reduce( - (command, segment) => command.commands.find((child) => child.name() === segment)!, - root, - ); - const screen = renderScreen(`/agentcore/${path}`, { - core, - globalConfig: DEFAULT_GLOBAL_CONFIG, - withContext: (ctx) => ctx.withValue(CommandKey, launch), - }); - await waitForText(screen.lastFrame, "the platform for production AI agents"); - expectNoCoreCalls(core); - }); - - test("public project creation still opens without imperative commands", async () => { - const core = new GateTestCore(); - const screen = renderScreen("/agentcore/create", { - core, - globalConfig: DEFAULT_GLOBAL_CONFIG, - }); - await waitForText(screen.lastFrame, "name your project"); - expectNoCoreCalls(core); - }); - - test.each(["harness", "runtime"] as const)( - "the actual project invoke %s command can mount and invoke its resource", - async (family) => { - const core = new TestCoreClient(); - core.harness.setGetResponse({ - harness: { - harnessId: "update", - arn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:harness/update", - }, - } as GetHarnessResponse); - core.runtime.setGetResponse({ - agentRuntimeId: "update", - agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/update", - status: "READY", - protocolConfiguration: { serverProtocol: "HTTP" }, - } as GetAgentRuntimeResponse); - const screen = renderScreen( - `/agentcore/${family}/invoke/update${family === "runtime" ? "/DEFAULT" : ""}`, - { - core, - globalConfig: DEFAULT_GLOBAL_CONFIG, - withContext: (ctx) => ctx.withValue(CommandKey, projectLaunch(core, family)), - }, - ); - await waitForText(screen.lastFrame, family === "harness" ? "send a message" : "payload"); - await screen.write(family === "harness" ? "hello" : '{"prompt":"hello"}'); - await screen.press("return"); - await waitFor(() => - core[family].calls.some( - (call) => call.method === (family === "harness" ? "invokeHarness" : "invokeRuntime"), - ), - ); - }, - ); - +describe("public project invocation does not enable other commands", () => { test.each([ ["harness", "harness/invoke"], ["runtime", "runtime/invoke"], @@ -353,13 +89,16 @@ describe("public project invoke exception", () => { ["harness", "harness/update/update"], ["runtime", "gateway/invoke/update"], ] as const)("project invoke %s does not authorize %s", async (family, path) => { - const core = new GateTestCore(); + const core = new TestCoreClient(); + const launch = compiledRootCommand(core, DEFAULT_GLOBAL_CONFIG) + .commands.find((command) => command.name() === "invoke")! + .commands.find((command) => command.name() === family)!; const screen = renderScreen(`/agentcore/${path}`, { core, globalConfig: DEFAULT_GLOBAL_CONFIG, - withContext: (ctx) => ctx.withValue(CommandKey, projectLaunch(core, family)), + withContext: (ctx) => ctx.withValue(CommandKey, launch), }); await waitForText(screen.lastFrame, "the platform for production AI agents"); - expectNoCoreCalls(core); + expectNoResourceCalls(core); }); }); From 91dd8aab231e1108d2778aa4445bb6e4b10f25d4 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 24 Sep 2026 23:45:18 +0000 Subject: [PATCH 8/9] test(harness): reuse linked-resource fixtures for flag-off coverage --- src/handlers/harness/get/get.screen.test.tsx | 21 ++++++ .../project/status/status.screen.test.tsx | 67 ------------------- 2 files changed, 21 insertions(+), 67 deletions(-) diff --git a/src/handlers/harness/get/get.screen.test.tsx b/src/handlers/harness/get/get.screen.test.tsx index 58479cab8f..33ef6c89fa 100644 --- a/src/handlers/harness/get/get.screen.test.tsx +++ b/src/handlers/harness/get/get.screen.test.tsx @@ -9,6 +9,7 @@ import type { Harness, } from "@aws-sdk/client-bedrock-agentcore-control"; import { + renderScreen, renderImperativeScreen, waitForText, waitFor, @@ -289,6 +290,26 @@ async function focusTree(r: ReturnType, row = 0) } describe("harness hub linked resources", () => { + test.each([ + [2, "gateway", "getGateway", GATEWAY_ID], + [3, "identity", "getOauth2CredentialProvider", "github-oauth"], + ] as const)( + "linked row %i remains readable with imperative commands off", + async (row, family, method, id) => { + const core = new TestCoreClient(); + core.harness.setGetResponse({ harness: linkedHarness() }); + const r = renderScreen("/agentcore/harness/get/MyHarness-abc123", { core }); + await waitForText(r.lastFrame, "linked resources"); + for (let press = 0; press < 3 + row; press++) await r.press("down"); + await r.press("return"); + await waitFor(() => core[family].calls.some((call) => call.method === method)); + expect(core[family].calls.find((call) => call.method === method)!.args).toEqual([ + id, + expect.objectContaining({ region: LINK_REGION }), + ]); + }, + ); + test("lists one row per linked resource under a titled divider", async () => { const { r } = linkedHubScreen(); diff --git a/src/handlers/project/status/status.screen.test.tsx b/src/handlers/project/status/status.screen.test.tsx index 3940581ff0..70ee974134 100644 --- a/src/handlers/project/status/status.screen.test.tsx +++ b/src/handlers/project/status/status.screen.test.tsx @@ -2,10 +2,8 @@ import { afterEach, describe, expect, test } from "bun:test"; import type { GetAgentRuntimeEndpointResponse, GetAgentRuntimeResponse, - GetGatewayResponse, GetHarnessResponse, GetMemoryOutput, - GetOauth2CredentialProviderResponse, ListAgentRuntimeEndpointsResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import { DEFAULT_GLOBAL_CONFIG, type GlobalConfig } from "../../../globalConfig"; @@ -306,71 +304,6 @@ describe("project status screen", () => { ]); }); - test.each(["gateway", "oauth2"])( - "a project Harness's linked %s remains readable with imperative commands off", - async (linked) => { - const value = core([deployed("harness", "support", { arn: `${ARN}:harness/${HARNESS_ID}` })]); - value.harness.setGetResponse({ - harness: { - harnessId: HARNESS_ID, - harnessName: "support", - arn: `${ARN}:harness/${HARNESS_ID}`, - tools: [ - { - type: "agentcore_gateway", - config: { - agentCoreGateway: { - gatewayArn: `${ARN}:gateway/update`, - outboundAuth: { - oauth: { - providerArn: `${ARN}:token-vault/default/oauth2credentialprovider/delete`, - }, - }, - }, - }, - }, - ], - }, - } as GetHarnessResponse); - value.gateway.setGetResponse({ - gatewayId: "update", - name: "linked-gateway", - gatewayArn: `${ARN}:gateway/update`, - status: "READY", - } as GetGatewayResponse); - value.identity.setGetOauth2Response({ - name: "delete", - status: "READY", - } as GetOauth2CredentialProviderResponse); - const screen = renderStatus( - value, - project({ harnesses: [{ name: "support", path: "app/support" }] }), - ); - await waitForGroup(screen, "support"); - await screen.press("down"); - await screen.press("return"); - await waitForText(screen.lastFrame, "linked resources"); - for (let press = 0; press < (linked === "gateway" ? 3 : 4); press++) { - await screen.press("down"); - } - expect(focusedLine(screen.lastFrame())).toContain(linked); - await screen.press("return"); - await waitForText(screen.lastFrame, "READY"); - if (linked === "gateway") { - const call = value.gateway.calls.find(({ method }) => method === "getGateway")!; - expect(call.args).toEqual(["update", expect.objectContaining({ region: TARGET.region })]); - expect(flatFrame(screen.lastFrame)).toContain("browse every Target"); - } else { - const call = value.identity.calls.find( - ({ method }) => method === "getOauth2CredentialProvider", - )!; - expect(call.args).toEqual(["delete", expect.objectContaining({ region: TARGET.region })]); - expect(flatFrame(screen.lastFrame)).toContain("show the full JSON definition"); - } - expect(value.harness.calls.every(({ method }) => method === "getHarness")).toBe(true); - }, - ); - test("escape from a detail page returns to the status screen", async () => { const screen = renderStatus(core()); From 51d94363970574263e38c7558616b106703c55b0 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 24 Sep 2026 23:46:06 +0000 Subject: [PATCH 9/9] test(runtime): fold project target regressions into existing suites --- .../project/invoke/invoke.screen.test.tsx | 13 +- .../runtime/invoke/invoke.screen.test.tsx | 53 ++++ .../invoke/project-target.screen.test.tsx | 241 ------------------ 3 files changed, 65 insertions(+), 242 deletions(-) delete mode 100644 src/handlers/runtime/invoke/project-target.screen.test.tsx diff --git a/src/handlers/project/invoke/invoke.screen.test.tsx b/src/handlers/project/invoke/invoke.screen.test.tsx index 66965c652e..7372719ccc 100644 --- a/src/handlers/project/invoke/invoke.screen.test.tsx +++ b/src/handlers/project/invoke/invoke.screen.test.tsx @@ -296,7 +296,7 @@ describe("project invoke picker", () => { }); }); - test("uses the existing Runtime endpoint picker before its JSON console", async () => { + test("project Runtime invocation keeps target switching within the selected resource", async () => { const value = core(); const screen = renderScreen("/agentcore/invoke", { core: value, @@ -316,5 +316,16 @@ describe("project invoke picker", () => { endpointUrl: undefined, credentials: TARGET_CREDENTIALS, }); + await screen.write("\x14"); + await waitForText(screen.lastFrame, "choose another endpoint"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "Enter JSON payload"); + await screen.write("{}"); + await screen.press("return"); + await waitFor(() => value.runtime.calls.some((call) => call.method === "invokeRuntime")); + expect( + value.runtime.calls.find((call) => call.method === "invokeRuntime")!.args[0], + ).toMatchObject({ runtimeId: "runtime-123", qualifier: "DEFAULT" }); + expect(value.runtime.calls.some((call) => call.method === "listRuntimes")).toBe(false); }); }); diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index 840d02e02e..2ee3f48627 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -8,11 +8,15 @@ import type { import type { RuntimeInvokeRequest } from "../types"; import { cleanupScreens, + compiledRootCommand, + renderScreen, renderImperativeScreen, TestCoreClient, waitFor, waitForText, } from "../../../testing"; +import { DEFAULT_GLOBAL_CONFIG } from "../../../globalConfig"; +import { CommandKey } from "../../../router"; import { RuntimeInvokeLaunchContextKey } from "./launchContext"; const REGION = "us-east-1"; @@ -70,6 +74,55 @@ function displayedSessionId(frame: string | undefined): string | undefined { } describe("Runtime invoke routing", () => { + test("project invocation only switches endpoints while imperative commands are off", async () => { + const core = new TestCoreClient(); + core.runtime + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setListEndpointsResponse({ + runtimeEndpoints: [endpoint(), endpoint({ name: "canary", id: "canary" })], + }); + const launch = compiledRootCommand(core) + .commands.find((command) => command.name() === "invoke")! + .commands.find((command) => command.name() === "runtime")!; + const screen = renderScreen(CONSOLE_PATH, { + core, + globalConfig: DEFAULT_GLOBAL_CONFIG, + withContext: (ctx) => ctx.withValue(CommandKey, launch), + }); + await waitForText(screen.lastFrame, "Enter JSON payload"); + await screen.write("\x14"); + await waitForText(screen.lastFrame, "choose another endpoint"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "Enter JSON payload"); + await screen.write("\x14"); + await waitForText(screen.lastFrame, "canary"); + await screen.press("down"); + await screen.press("return"); + await waitForText(screen.lastFrame, "Enter JSON payload"); + await screen.write("{}"); + await screen.press("return"); + await waitFor(() => invokeRequests(core).length === 1); + expect(invokeRequests(core)[0]).toMatchObject({ runtimeId: RUNTIME_ID, qualifier: "canary" }); + expect(core.runtime.calls.some((call) => call.method === "listRuntimes")).toBe(false); + }); + + test("back from a project's initial endpoint picker cannot open the account Runtime picker", async () => { + const core = new TestCoreClient(); + core.runtime.setListEndpointsResponse({ runtimeEndpoints: [endpoint()] }); + const launch = compiledRootCommand(core) + .commands.find((command) => command.name() === "invoke")! + .commands.find((command) => command.name() === "runtime")!; + const screen = renderScreen(`/agentcore/runtime/invoke/${RUNTIME_ID}`, { + core, + globalConfig: DEFAULT_GLOBAL_CONFIG, + withContext: (ctx) => ctx.withValue(CommandKey, launch), + }); + await waitForText(screen.lastFrame, QUALIFIER); + await screen.press("escape"); + await waitForText(screen.lastFrame, "the platform for production AI agents"); + expect(core.runtime.calls.some((call) => call.method === "listRuntimes")).toBe(false); + }); + test("selects a Runtime and endpoint before opening one console", async () => { const runtimeId = "runtime/blue one"; const qualifier = "prod/green one"; diff --git a/src/handlers/runtime/invoke/project-target.screen.test.tsx b/src/handlers/runtime/invoke/project-target.screen.test.tsx deleted file mode 100644 index 5d84e2a5d6..0000000000 --- a/src/handlers/runtime/invoke/project-target.screen.test.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import type { - AgentRuntimeEndpoint, - GetAgentRuntimeResponse, -} from "@aws-sdk/client-bedrock-agentcore-control"; -import { DEFAULT_GLOBAL_CONFIG } from "../../../globalConfig"; -import { ProjectSpecSchema } from "../../../projectSchemas/project"; -import { CommandKey, ProjectKey } from "../../../router"; -import { - cleanupScreens, - compiledRootCommand, - flatFrame, - renderScreen, - TestCoreClient, - waitFor, - waitForText, -} from "../../../testing"; -import type { Project } from "../../project/types"; -import type { RuntimeInvokeRequest } from "../types"; - -afterEach(cleanupScreens); - -const PROJECT_RUNTIME = "project-runtime"; -const OTHER_RUNTIME = "standalone-other"; -const TARGET = { name: "default", account: "123456789012", region: "us-east-1" } as const; -const TARGET_CREDENTIALS = async () => ({ - accessKeyId: "test-access-key", - secretAccessKey: "test-secret-key", -}); -const PROJECT: Project = { - name: "orders", - rootPath: "/tmp/orders", - spec: ProjectSpecSchema.parse({ - name: "orders", - version: 2, - runtimes: [ - { - name: "checkout", - build: "CodeZip", - entrypoint: "main.py", - codeLocation: "app/checkout", - runtimeVersion: "PYTHON_3_14", - }, - ], - }), -}; - -type Launch = "CLI context" | "project picker"; - -class ProjectTargetCore extends TestCoreClient { - constructor() { - super(); - this.projectManager.listTargets = async () => [TARGET]; - this.projectManager.resolveDeployedResources = async () => ({ - target: TARGET, - resources: [ - { - resourceType: "runtime", - name: "checkout", - id: PROJECT_RUNTIME, - target: TARGET, - credentialProvider: TARGET_CREDENTIALS, - }, - ], - }); - this.runtime.setListResponse({ - agentRuntimes: [ - { - agentRuntimeId: OTHER_RUNTIME, - agentRuntimeArn: `arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/${OTHER_RUNTIME}`, - agentRuntimeVersion: "1", - agentRuntimeName: "standalone", - description: "Runtime outside the project", - lastUpdatedAt: new Date(0), - status: "READY", - }, - ], - }); - this.selectRuntime(PROJECT_RUNTIME); - } - - selectRuntime(id: string) { - const arn = `arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/${id}`; - this.runtime.setGetResponse({ - agentRuntimeId: id, - agentRuntimeArn: arn, - status: "READY", - protocolConfiguration: { serverProtocol: "HTTP" }, - } as GetAgentRuntimeResponse); - this.runtime.setListEndpointsResponse({ - runtimeEndpoints: ["DEFAULT", "canary"].map((name): AgentRuntimeEndpoint => ({ - id: name, - name, - agentRuntimeArn: arn, - agentRuntimeEndpointArn: `${arn}/runtime-endpoint/${name}`, - liveVersion: "1", - targetVersion: "1", - status: "READY", - createdAt: new Date(0), - lastUpdatedAt: new Date(0), - })), - }); - } - - requests(): RuntimeInvokeRequest[] { - return this.runtime.calls - .filter((call) => call.method === "invokeRuntime") - .map((call) => call.args[0] as RuntimeInvokeRequest); - } - - renderCli(enabled: boolean, qualifier?: string) { - const globalConfig = { ...DEFAULT_GLOBAL_CONFIG, "imperative-commands": enabled }; - const launch = compiledRootCommand(this, globalConfig) - .commands.find((command) => command.name() === "invoke")! - .commands.find((command) => command.name() === "runtime")!; - return renderScreen( - `/agentcore/runtime/invoke/${PROJECT_RUNTIME}${qualifier ? `/${qualifier}` : ""}`, - { - core: this, - globalConfig, - withContext: (ctx) => ctx.withValue(CommandKey, launch).withValue(ProjectKey, PROJECT), - }, - ); - } - - async openConsole(launch: Launch, enabled: boolean) { - if (launch === "CLI context") { - const screen = this.renderCli(enabled, "DEFAULT"); - await waitForText(screen.lastFrame, "Enter JSON payload"); - return screen; - } - const screen = renderScreen("/agentcore/invoke", { - core: this, - globalConfig: { ...DEFAULT_GLOBAL_CONFIG, "imperative-commands": enabled }, - withContext: (ctx) => ctx.withValue(ProjectKey, PROJECT), - }); - await waitForText(screen.lastFrame, "checkout"); - await screen.press("return"); - await waitForText(screen.lastFrame, "DEFAULT"); - await screen.press("return"); - await waitForText(screen.lastFrame, "Enter JSON payload"); - return screen; - } -} - -describe("project Runtime target selection", () => { - test.each(["CLI context", "project picker"] as const)( - "%s with imperative commands off keeps target selection scoped to the project Runtime", - async (launch) => { - const core = new ProjectTargetCore(); - const screen = await core.openConsole(launch, false); - - await screen.write("\x14"); - await waitForText(screen.lastFrame, "choose another endpoint"); - expect(flatFrame(screen.lastFrame)).toContain(PROJECT_RUNTIME); - expect(screen.lastFrame()).not.toContain("choose another Runtime"); - expect(core.runtime.calls.some((call) => call.method === "listRuntimes")).toBe(false); - - await screen.press("escape"); - await waitForText(screen.lastFrame, "Enter JSON payload"); - await screen.write('{"turn":1}'); - await screen.press("return"); - await waitFor(() => core.requests().length === 1); - await waitForText(screen.lastFrame, "complete"); - expect(core.requests()[0]).toMatchObject({ - runtimeId: PROJECT_RUNTIME, - qualifier: "DEFAULT", - }); - - await screen.write("\x14"); - await waitForText(screen.lastFrame, "canary"); - await screen.press("down"); - await screen.press("return"); - await waitForText(screen.lastFrame, "Enter JSON payload"); - await screen.write('{"turn":2}'); - await screen.press("return"); - await waitFor(() => core.requests().length === 2); - expect(core.requests()[1]).toMatchObject({ - runtimeId: PROJECT_RUNTIME, - qualifier: "canary", - }); - expect(core.runtime.calls.some((call) => call.method === "listRuntimes")).toBe(false); - expect( - core.runtime.calls - .filter((call) => call.method === "listRuntimeEndpoints") - .every((call) => call.args[0] === PROJECT_RUNTIME), - ).toBe(true); - }, - ); - - test.each(["CLI context", "project picker"] as const)( - "%s with imperative commands on can select and invoke another Runtime", - async (launch) => { - const core = new ProjectTargetCore(); - const screen = await core.openConsole(launch, true); - - await screen.write("\x14"); - await waitForText(screen.lastFrame, "choose another Runtime"); - await waitForText(screen.lastFrame, OTHER_RUNTIME); - expect(core.runtime.calls.some((call) => call.method === "listRuntimes")).toBe(true); - core.selectRuntime(OTHER_RUNTIME); - await screen.press("return"); - await waitForText(screen.lastFrame, "choose another endpoint"); - await waitForText(screen.lastFrame, "DEFAULT"); - await screen.press("escape"); - await waitForText(screen.lastFrame, "choose another Runtime"); - await waitForText(screen.lastFrame, OTHER_RUNTIME); - await screen.press("return"); - await waitForText(screen.lastFrame, "choose another endpoint"); - await waitForText(screen.lastFrame, "DEFAULT"); - await screen.press("return"); - await waitForText(screen.lastFrame, "Enter JSON payload"); - await screen.write('{"turn":1}'); - await screen.press("return"); - await waitFor(() => core.requests().length === 1); - expect(core.requests()[0]).toMatchObject({ - runtimeId: OTHER_RUNTIME, - qualifier: "DEFAULT", - }); - }, - ); - - test.each([false, true])( - "initial CLI endpoint picker Escape respects imperative-commands=%s", - async (enabled) => { - const core = new ProjectTargetCore(); - const screen = core.renderCli(enabled); - await waitForText(screen.lastFrame, "choose an endpoint to invoke"); - await waitForText(screen.lastFrame, "DEFAULT"); - - await screen.press("escape"); - await waitForText( - screen.lastFrame, - enabled ? "choose a Runtime to invoke" : "the platform for production AI agents", - ); - if (enabled) await waitForText(screen.lastFrame, OTHER_RUNTIME); - expect(core.runtime.calls.some((call) => call.method === "listRuntimes")).toBe(enabled); - expect(core.requests()).toEqual([]); - }, - ); -});