diff --git a/src/components/CommandGate.test.tsx b/src/components/CommandGate.test.tsx new file mode 100644 index 0000000000..47673d7e2b --- /dev/null +++ b/src/components/CommandGate.test.tsx @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { DEFAULT_GLOBAL_CONFIG } from "../globalConfig"; +import { CommandKey } from "../router"; +import { + cleanupScreens, + compiledRootCommand, + renderScreen, + TestCoreClient, + waitForText, +} from "../testing"; + +afterEach(cleanupScreens); + +function expectNoResourceCalls(core: TestCoreClient) { + expect([ + ...core.harness.calls, + ...core.runtime.calls, + ...core.gateway.calls, + ...core.policy.calls, + ]).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", +]; + +describe("disabled imperative routes", () => { + test.each(MUTATION_ROUTES)("%s redirects before mounting its screen", async (path) => { + const core = new TestCoreClient(); + 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"); + expectNoResourceCalls(core); + }); + + test("unavailable Gateway creation shows project guidance, not another command's help", async () => { + const core = new TestCoreClient(); + const screen = renderScreen("/agentcore/gateway/create", { + core, + globalConfig: DEFAULT_GLOBAL_CONFIG, + }); + await waitForText(screen.lastFrame, "Create an AgentCore Gateway"); + expect(screen.lastFrame()).not.toContain("this command runs from the command line"); + expectNoResourceCalls(core); + }); +}); + +describe("public project invocation does not enable other commands", () => { + 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 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, launch), + }); + await waitForText(screen.lastFrame, "the platform for production AI agents"); + expectNoResourceCalls(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/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/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/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/project/invoke/invoke.screen.test.tsx b/src/handlers/project/invoke/invoke.screen.test.tsx index f93760d815..7372719ccc 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([ @@ -243,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, @@ -263,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/project/status/status.screen.test.tsx b/src/handlers/project/status/status.screen.test.tsx index 556d5b3f1b..70ee974134 100644 --- a/src/handlers/project/status/status.screen.test.tsx +++ b/src/handlers/project/status/status.screen.test.tsx @@ -1,9 +1,12 @@ import { afterEach, describe, expect, test } from "bun:test"; import type { + GetAgentRuntimeEndpointResponse, GetAgentRuntimeResponse, GetHarnessResponse, GetMemoryOutput, + 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 +14,7 @@ import { cleanupScreens, flatFrame, inTempDirectory, + IMPERATIVE_GLOBAL_CONFIG, renderScreen, TestCoreClient, waitFor, @@ -102,9 +106,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 +223,87 @@ 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("escape from a detail page returns to the status screen", async () => { const screen = renderStatus(core()); @@ -290,9 +380,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: () => 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/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" }, diff --git a/src/handlers/runtime/shell/shell.screen.test.tsx b/src/handlers/runtime/shell/shell.screen.test.tsx index fce8071780..631f0fd7e0 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, @@ -79,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(), @@ -102,6 +128,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 +161,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 +178,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 +193,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)