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)