diff --git a/README.md b/README.md index 3845176a7a..00c8213304 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ It gives you two ways to work, from the same package: ```bash agentcore # launch the interactive TUI agentcore status --json # scriptable, machine-readable output +agentcore exec --runtime --command "uname -a" ``` ## What problem does it solve? @@ -50,6 +51,8 @@ Project commands manage local project specifications and their deployments. | Command | Purpose | | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | | `create`, `add`, `export`, `remove`, `dev`, `deploy`, `invoke`, `log`, `traces`, `status`, `build` | Create, develop, build, deploy, invoke, and inspect a project | +| `exec` | Run a command in a Runtime or Harness | +| `shell` | Open an interactive shell in a Runtime | | `eval` | Evaluate agents, manage datasets and configurations, and run experiments | | `feedback` | Submit feedback | | `config` | Read and write global CLI settings | diff --git a/docs/harness-project-configuration.md b/docs/harness-project-configuration.md index a5e415ced5..cf4dce7b9c 100644 --- a/docs/harness-project-configuration.md +++ b/docs/harness-project-configuration.md @@ -329,7 +329,7 @@ tool from the tool entry named `research`. `@research` allows all tools from that entry, and patterns such as `@research/read_*` select matching tools. This is not an IAM policy and does not grant access to AWS resources. It also -does not restrict direct command execution through `harness exec`. +does not restrict direct command execution through `agentcore exec --harness`. Do not use an empty list as a deny-all policy: the current CDK mapper omits an empty list when creating the Harness. diff --git a/src/components/CliOnlyScreen.test.tsx b/src/components/CliOnlyScreen.test.tsx index 66e0ecff06..fee60a2b17 100644 --- a/src/components/CliOnlyScreen.test.tsx +++ b/src/components/CliOnlyScreen.test.tsx @@ -40,7 +40,18 @@ describe("menus list command-line-only subcommands below a divider", () => { await waitForText(r.lastFrame, "command line only"); expect(menuEntries(r.lastFrame()!)).toEqual({ - screens: ["create", "add", "remove", "deploy", "invoke", "status", "build", "eval"], + screens: [ + "create", + "add", + "remove", + "deploy", + "invoke", + "status", + "build", + "eval", + "exec", + "shell", + ], cliOnly: ["export", "dev", "log", "traces", "feedback", "config", "update"], }); r.unmount(); diff --git a/src/components/Root.tsx b/src/components/Root.tsx index f86b1b93d6..61654b4a5f 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -43,6 +43,7 @@ import { MemoryGetJsonScreen, MemoryGetScreen } from "../handlers/memory/get/scr import { MemoryListScreen } from "../handlers/memory/list/screen.tsx"; import { RuntimeInvokeScreen } from "../handlers/runtime/invoke/screen.tsx"; import { RuntimeShellScreen } from "../handlers/runtime/shell/screen.tsx"; +import { RuntimeExecScreen } from "../handlers/runtime/exec/screen.tsx"; import { EvalScreen } from "../handlers/eval/screen.tsx"; import { EvaluatorScreen } from "../handlers/eval/evaluator/screen.tsx"; import { EvaluatorListScreen } from "../handlers/eval/evaluator/list/screen.tsx"; @@ -480,6 +481,15 @@ function RouteTable({ ctx, core }: ScreenProps) { path="agentcore/runtime/shell/:runtimeId/:qualifier" element={} /> + } /> + } + /> + } + /> } /> { await waitForText(r.lastFrame, "list"); const frame = r.lastFrame()!; - for (const sub of ["get", "list", "create", "update", "delete", "invoke", "exec"]) { + for (const sub of ["get", "list", "create", "update", "delete", "invoke"]) { expect(frame).toContain(sub); } r.unmount(); diff --git a/src/handlers/harness/exec/exec.screen.test.tsx b/src/handlers/exec/exec.screen.test.tsx similarity index 99% rename from src/handlers/harness/exec/exec.screen.test.tsx rename to src/handlers/exec/exec.screen.test.tsx index 94bed2947c..ab2ad1be79 100644 --- a/src/handlers/harness/exec/exec.screen.test.tsx +++ b/src/handlers/exec/exec.screen.test.tsx @@ -12,7 +12,7 @@ import { StreamController, TestCoreClient, waitFor, -} from "../../../testing"; +} from "../../testing"; afterEach(cleanupScreens); diff --git a/src/handlers/harness/exec/exec.test.tsx b/src/handlers/exec/exec.test.tsx similarity index 82% rename from src/handlers/harness/exec/exec.test.tsx rename to src/handlers/exec/exec.test.tsx index 801f9ee734..93cb0f099f 100644 --- a/src/handlers/harness/exec/exec.test.tsx +++ b/src/handlers/exec/exec.test.tsx @@ -4,18 +4,18 @@ import type { InvokeAgentRuntimeCommandStreamOutput, } from "@aws-sdk/client-bedrock-agentcore"; import type { GetHarnessResponse } from "@aws-sdk/client-bedrock-agentcore-control"; -import { createRootHandler } from "../../index"; +import { createRootHandler } from "../index"; import { IMPERATIVE_GLOBAL_CONFIG, createSilentLogger, expectError, TestCoreClient, testIO, -} from "../../../testing"; -import { TestGlobalConfigAccessor } from "../../../testing/"; -import { InputValidationError } from "../../../errors"; +} from "../../testing"; +import { TestGlobalConfigAccessor } from "../../testing/"; +import { InputValidationError } from "../../errors"; -// Command-flow tests for `harness exec`, driven through the real root handler. +// Command-flow tests for top-level `exec --harness`, driven through the real root handler. // Like the invoke suite, these use a TestCoreClient because the command // response is an AsyncIterable stream that fixtures cannot capture. @@ -51,16 +51,9 @@ async function run(args: string[], configure?: (core: TestCoreClient) => void) { return { core, stdout: io.stdout() }; } -describe("harness exec", () => { +describe("exec --harness", () => { test("folds the command stream into JSON output", async () => { - const { stdout } = await run([ - "harness", - "exec", - "--id", - "MyHarness-abc123", - "--command", - "ls", - ]); + const { stdout } = await run(["exec", "--harness", "MyHarness-abc123", "--command", "ls"]); expect(JSON.parse(stdout)).toEqual({ command: "ls", @@ -72,9 +65,8 @@ describe("harness exec", () => { test("addresses the command to the harness ARN with the given body", async () => { const { core } = await run([ - "harness", "exec", - "--id", + "--harness", "MyHarness-abc123", "--command", "uname -a", @@ -93,9 +85,8 @@ describe("harness exec", () => { test("--session-id and --qualifier pass through and the session id is echoed", async () => { const sessionId = "exec-session-id-that-is-long-enough!"; const { core, stdout } = await run([ - "harness", "exec", - "--id", + "--harness", "MyHarness-abc123", "--command", "ls", @@ -114,7 +105,7 @@ describe("harness exec", () => { test("a failing command reports its exit code and error status", async () => { const { stdout } = await run( - ["harness", "exec", "--id", "MyHarness-abc123", "--command", "false"], + ["exec", "--harness", "MyHarness-abc123", "--command", "false"], (core) => core.harness.setExecEvents( { chunk: { contentDelta: { stderr: "boom\n" } } }, @@ -125,15 +116,19 @@ describe("harness exec", () => { expect(JSON.parse(stdout)).toMatchObject({ exitCode: 1, status: "error", output: "boom\n" }); }); - test("errors when --id is omitted", async () => { - await expectError(run(["harness", "exec", "--command", "ls"]), /--id/, InputValidationError); + test("errors when --harness is omitted", async () => { + await expectError( + run(["exec", "--command", "ls"]), + /--runtime or --harness/, + InputValidationError, + ); }); // Without --command (and outside JSON mode) the handler opens the interactive // exec screen instead — that path is covered by the screen tests, since the // test IO streams cannot host an Ink render. test("errors when --command is omitted in JSON mode", async () => { - await expect(run(["harness", "exec", "--id", "MyHarness-abc123", "--json"])).rejects.toThrow( + await expect(run(["exec", "--harness", "MyHarness-abc123", "--json"])).rejects.toThrow( /--command/, ); }); diff --git a/src/handlers/exec/index.tsx b/src/handlers/exec/index.tsx new file mode 100644 index 0000000000..738b2a10d5 --- /dev/null +++ b/src/handlers/exec/index.tsx @@ -0,0 +1,114 @@ +import z from "zod"; +import { ResourceNotFoundError, InputValidationError } from "../../errors"; +import { createHandler, flag } from "../../router"; +import { JsonKey } from "../keys"; +import type { AppIO } from "../../io"; +import type { Core } from "../types"; +import { + assertMutuallyExclusiveFlags, + contextForResource, + coreOptsFromCtx, + toResourceArn, +} from "../utils"; +import { JsonRendererKey, renderTuiAt } from "../../tui"; +import { regionFromArn, serviceIdFromArn } from "../../core/arn"; +import { RegionKey } from "../keys"; +import { invokeExecCommand } from "./operation"; + +export const createExecHandler = (core: Core, io: AppIO) => + createHandler({ + name: "exec", + description: "run a shell command in a Runtime or harness", + flags: [ + flag( + "runtime", + "the name a Runtime in the project, or ID of a Runtime in the account", + z.string().min(1).optional(), + ), + flag( + "harness", + "the name of a harness in the project, or ID of a harness in the account", + z.string().min(1).optional(), + ), + flag("command", "the shell command to run", z.string().optional()), + flag( + "session-id", + "the session ID to run in (33-100 characters)", + z.string().min(33).max(100).optional(), + ), + flag( + "qualifier", + "the endpoint qualifier to run in (default DEFAULT)", + z.string().optional(), + ), + flag( + "timeout", + "seconds to wait for the command (1-3600)", + z.number().int().min(1).max(3600).optional(), + ), + ], + handle: async (ctx, flags) => { + assertMutuallyExclusiveFlags(flags, ["runtime", "harness"]); + + const resourceType = + flags.runtime !== undefined + ? "runtime" + : flags.harness !== undefined + ? "harness" + : undefined; + const identifier = flags.runtime ?? flags.harness; + if (resourceType === undefined || identifier === undefined) { + throw new InputValidationError("specify one of --runtime or --harness"); + } + + const resourceCtx = await contextForResource({ + core, + context: ctx, + resourceType, + identifier, + }); + const resourceArn = await toResourceArn({ + core, + context: resourceCtx, + resourceType, + identifier, + }); + if (resourceArn === undefined) { + throw new ResourceNotFoundError( + `${resourceType === "runtime" ? "Runtime" : "Harness"} '${identifier}' was not found`, + ); + } + + const resourceRegion = regionFromArn(resourceArn); + const resolvedCtx = resourceRegion + ? resourceCtx.withValue(RegionKey, resourceRegion) + : resourceCtx; + const resourceId = serviceIdFromArn(resourceArn); + if (flags.command === undefined) { + if (ctx.require(JsonKey)) { + throw new InputValidationError("required option '--command ' not specified"); + } + let path = `/agentcore/${resourceType === "runtime" ? "runtime/exec" : "harness/exec"}/${encodeURIComponent(resourceId)}`; + if (flags["session-id"]) path += `/${encodeURIComponent(flags["session-id"])}`; + const params = new URLSearchParams(); + if (flags.qualifier) params.set("qualifier", flags.qualifier); + if (flags.timeout !== undefined) params.set("timeout", String(flags.timeout)); + if (params.size > 0) path += `?${params}`; + await renderTuiAt(path, resolvedCtx, core, io); + return; + } + + const result = await invokeExecCommand({ + core, + input: { + resourceArn, + command: flags.command, + runtimeSessionId: flags["session-id"], + qualifier: flags.qualifier ?? "DEFAULT", + timeout: flags.timeout, + }, + options: coreOptsFromCtx(resolvedCtx), + }); + ctx.require(JsonRendererKey).renderJson(result); + }, + }); diff --git a/src/handlers/exec/operation.ts b/src/handlers/exec/operation.ts new file mode 100644 index 0000000000..b52b41e3a0 --- /dev/null +++ b/src/handlers/exec/operation.ts @@ -0,0 +1,50 @@ +import type { InvokeAgentRuntimeCommandRequest } from "@aws-sdk/client-bedrock-agentcore"; +import type { CoreOptions } from "../../core/types"; +import type { Core } from "../types"; +import { applyExecEvent, finishExec, newExecItem } from "../harness/invoke/transcript"; + +export type ExecInput = { + resourceArn: string; + command: string; + runtimeSessionId?: string; + qualifier: string; + timeout?: number; +}; + +export type ExecResult = { + sessionId?: string; + command: string; + exitCode?: number; + status: "running" | "success" | "error"; + output: string; +}; + +export async function invokeExecCommand({ + core, + input, + options, + signal, +}: { + core: Core; + input: ExecInput; + options: CoreOptions; + signal?: AbortSignal; +}): Promise { + const request: InvokeAgentRuntimeCommandRequest = { + agentRuntimeArn: input.resourceArn, + qualifier: input.qualifier, + runtimeSessionId: input.runtimeSessionId, + body: { command: input.command, timeout: input.timeout }, + }; + const response = await core.harness.invokeAgentRuntimeCommand(request, options, signal); + const item = newExecItem(input.command); + for await (const event of response.stream ?? []) applyExecEvent(item, event); + finishExec(item); + return { + sessionId: input.runtimeSessionId ?? response.runtimeSessionId, + command: item.command, + exitCode: item.exitCode, + status: item.status, + output: item.output, + }; +} diff --git a/src/handlers/harness/exec/index.tsx b/src/handlers/harness/exec/index.tsx index bcae691ebe..b9d07e24e3 100644 --- a/src/handlers/harness/exec/index.tsx +++ b/src/handlers/harness/exec/index.tsx @@ -32,10 +32,6 @@ export const createExecHarnessHandler = (core: Core, io: AppIO) => ), ], handle: async (ctx, flags) => { - // Without a command, open the interactive exec screen at this harness — - // resuming the given session and targeting the given qualifier when - // passed. The one-shot CLI run below needs --command (and is the only - // shape JSON mode supports). if (!flags["command"]) { if (ctx.require(JsonKey)) { throw new InputValidationError("required option '--command ' not specified"); @@ -49,11 +45,8 @@ export const createExecHarnessHandler = (core: Core, io: AppIO) => const opts = coreOptsFromCtx(ctx); const detail = await core.harness.getHarness(flags["id"], opts); - const response = await core.harness.invokeAgentRuntimeCommand( { - // A harness-managed runtime cannot be addressed by its own runtime - // ARN; the service expects the harness ARN here. agentRuntimeArn: detail.harness?.arn, qualifier: flags["qualifier"] ?? "DEFAULT", runtimeSessionId: flags["session-id"], @@ -63,9 +56,7 @@ export const createExecHarnessHandler = (core: Core, io: AppIO) => ); const item = newExecItem(flags["command"]); - for await (const event of response.stream ?? []) { - applyExecEvent(item, event); - } + for await (const event of response.stream ?? []) applyExecEvent(item, event); finishExec(item); ctx.require(JsonRendererKey).renderJson({ diff --git a/src/handlers/harness/exec/screen.tsx b/src/handlers/harness/exec/screen.tsx index 6130b2ee86..437c698d85 100644 --- a/src/handlers/harness/exec/screen.tsx +++ b/src/handlers/harness/exec/screen.tsx @@ -3,11 +3,6 @@ import type { ScreenProps } from "../../types"; import { HarnessPicker } from "../../../components/HarnessPicker"; import { HarnessChat } from "../invoke/screen"; -// HarnessExecScreen is `harness exec` in the TUI: the same chat screen as -// invoke, but starting in exec mode ($ prompt, enter runs a shell command in -// the session's container). Ctrl+E flips between exec and chat at any time. -// Without a `:harnessId` route value it renders the harness picker. A -// `:sessionId` route value resumes that runtime session. export function HarnessExecScreen(props: ScreenProps) { const { harnessId, sessionId } = useParams(); const [search] = useSearchParams(); diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index 7f73e4ff07..be33a72c19 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -11,6 +11,8 @@ import { DebugKey, JsonKey, RegionKey } from "./keys.tsx"; import { createConfigHandler } from "./config/"; import { createProjectHandlers } from "./project/index.ts"; import { createUpdateHandler } from "./update/index.tsx"; +import { createExecHandler } from "./exec/index.tsx"; +import { createShellHandler } from "./shell/index.tsx"; import { renderTui } from "../tui"; import { withRegion, @@ -60,6 +62,8 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router "memory", "gateway", "eval", + "exec", + "shell", ); // `agentcore --version` prints the build-time package version. @@ -102,6 +106,8 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router root.handler(createPaymentHandler(core, io)); } root.handler(createEvalHandler(core, io)); + root.handler(createExecHandler(core, io)); + root.handler(createShellHandler(core, io)); root.handler(createFeedbackHandler(core, io)); root.handler(createConfigHandler()); root.handler(createUpdateHandler(io)); diff --git a/src/handlers/root.test.tsx b/src/handlers/root.test.tsx index 4d349954bf..eebd526bae 100644 --- a/src/handlers/root.test.tsx +++ b/src/handlers/root.test.tsx @@ -22,6 +22,8 @@ const PUBLIC_COMMANDS = [ "status", "build", "eval", + "exec", + "shell", "feedback", "config", "update", diff --git a/src/handlers/runtime/exec/index.tsx b/src/handlers/runtime/exec/index.tsx new file mode 100644 index 0000000000..04cf8fb0c8 --- /dev/null +++ b/src/handlers/runtime/exec/index.tsx @@ -0,0 +1,66 @@ +import z from "zod"; +import { createHandler, flag, PathKey } from "../../../router"; +import { InputValidationError } from "../../../errors"; +import { JsonKey } from "../../keys"; +import { JsonRendererKey, renderTuiAt } from "../../../tui"; +import type { AppIO } from "../../../io"; +import type { Core } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; +import { runtimeIdSchema } from "../invoke/request"; +import { invokeExecCommand } from "../../exec/operation"; + +export const createRuntimeExecHandler = (core: Core, io: AppIO) => + createHandler({ + name: "exec", + description: "run a shell command in a Runtime", + flags: [ + flag("id", "the ID of the Runtime", runtimeIdSchema), + flag("command", "the shell command to run", z.string().optional()), + flag( + "session-id", + "the Runtime session ID to run in (33-100 characters)", + z.string().min(33).max(100).optional(), + ), + flag( + "qualifier", + "the Runtime endpoint qualifier to run in (default DEFAULT)", + z.string().optional(), + ), + flag( + "timeout", + "seconds to wait for the command (1-3600)", + z.number().min(1).max(3600).optional(), + ), + ], + handle: async (ctx, flags) => { + if (!flags.command) { + if (ctx.require(JsonKey)) { + throw new InputValidationError("required option '--command ' not specified"); + } + let path = `${ctx.require(PathKey)}/${encodeURIComponent(flags.id)}`; + if (flags["session-id"]) path += `/${encodeURIComponent(flags["session-id"])}`; + const params = new URLSearchParams(); + if (flags.qualifier) params.set("qualifier", flags.qualifier); + if (flags.timeout !== undefined) params.set("timeout", String(flags.timeout)); + if (params.size > 0) path += `?${params}`; + await renderTuiAt(path, ctx, core, io); + return; + } + + const result = await invokeExecCommand({ + core, + input: { + resourceArn: (await core.runtime.getRuntime(flags.id, coreOptsFromCtx(ctx))) + .agentRuntimeArn!, + command: flags.command, + runtimeSessionId: flags["session-id"], + qualifier: flags.qualifier ?? "DEFAULT", + timeout: flags.timeout, + }, + options: coreOptsFromCtx(ctx), + }); + ctx.require(JsonRendererKey).renderJson(result); + }, + }); + +export { RuntimeExecScreen } from "./screen"; diff --git a/src/handlers/runtime/exec/screen.tsx b/src/handlers/runtime/exec/screen.tsx new file mode 100644 index 0000000000..4f25b54dde --- /dev/null +++ b/src/handlers/runtime/exec/screen.tsx @@ -0,0 +1,281 @@ +import { useEffect, useRef, useState } from "react"; +import { Box, Text, useInput, useWindowSize } from "ink"; +import { useQuery } from "@tanstack/react-query"; +import { useNavigate, useParams, useSearchParams } from "react-router"; +import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; +import type { ScreenProps } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; +import { RuntimePicker } from "../../../components/RuntimePicker"; +import { Layout } from "../../../components/Layout"; +import { Divider } from "../../../components/ui/divider"; +import { Spinner } from "../../../components/ui/spinner"; +import { TextInput } from "../../../components/ui/text-input"; +import { darkTheme, glyphs } from "../../../components/ui/_core.js"; +import { + applyExecEvent, + finishExec, + newExecItem, + newSessionId, + type ExecItem, + type TranscriptItem, +} from "../../harness/invoke/transcript"; + +const theme = darkTheme; + +type RuntimeExecItem = ExecItem | Extract; + +const execPath = (runtimeId?: string, sessionId?: string) => { + const parts = ["/agentcore/runtime/exec"]; + if (runtimeId !== undefined) parts.push(encodeURIComponent(runtimeId)); + if (sessionId !== undefined) parts.push(encodeURIComponent(sessionId)); + return parts.join("/"); +}; + +export function RuntimeExecScreen(props: ScreenProps) { + const { runtimeId, sessionId } = useParams(); + const [search] = useSearchParams(); + const navigate = useNavigate(); + const qualifier = search.get("qualifier") ?? "DEFAULT"; + const timeoutValue = search.get("timeout"); + const timeout = timeoutValue === null ? undefined : Number(timeoutValue); + + if (!runtimeId) { + return ( + navigate(execPath(id, sessionId))} + /> + ); + } + + return ( + + ); +} + +function RuntimeExecConsole({ + ctx, + core, + runtimeId, + sessionId: initialSessionId, + qualifier, + timeout, +}: ScreenProps & { + runtimeId: string; + sessionId?: string; + qualifier: string; + timeout?: number; +}) { + const opts = coreOptsFromCtx(ctx); + const { columns, rows } = useWindowSize(); + const navigate = useNavigate(); + const detail = useQuery({ + queryKey: ["runtime", opts.region, runtimeId], + queryFn: ({ signal }) => core.runtime.getRuntime(runtimeId, opts, signal), + }); + const [sessionId] = useState(() => initialSessionId ?? newSessionId()); + const [input, setInput] = useState(""); + const [streaming, setStreaming] = useState(false); + const [items, setItems] = useState([]); + const historyRef = useRef([]); + const streamingRef = useRef(false); + const aliveRef = useRef(true); + const abortRef = useRef(null); + const scrollRef = useRef(null); + const stickRef = useRef(true); + + useEffect(() => { + return () => { + aliveRef.current = false; + abortRef.current?.abort(); + }; + }, []); + + useEffect(() => { + if (stickRef.current) scrollRef.current?.scrollToBottom(); + }, [items]); + + const sync = () => { + if (aliveRef.current) setItems([...historyRef.current]); + }; + + const run = async (value: string) => { + const command = value.trim(); + const arn = detail.data?.agentRuntimeArn; + if (command === "" || streamingRef.current || !arn) return; + + setInput(""); + stickRef.current = true; + const item = newExecItem(command); + historyRef.current.push(item); + streamingRef.current = true; + setStreaming(true); + sync(); + + const controller = new AbortController(); + abortRef.current = controller; + try { + const response = await core.harness.invokeAgentRuntimeCommand( + { + agentRuntimeArn: arn, + qualifier, + runtimeSessionId: sessionId, + body: { command, ...(timeout !== undefined && { timeout }) }, + }, + opts, + controller.signal, + ); + for await (const event of response.stream ?? []) { + if (!aliveRef.current) return; + applyExecEvent(item, event); + sync(); + } + finishExec(item); + } catch (error) { + finishExec(item); + if (controller.signal.aborted || (error as Error)?.name === "AbortError") { + historyRef.current.push({ kind: "notice", text: "interrupted" }); + } else { + historyRef.current.push({ + kind: "error", + message: error instanceof Error ? error.message : String(error), + }); + } + } finally { + abortRef.current = null; + streamingRef.current = false; + if (aliveRef.current) { + setStreaming(false); + sync(); + } + } + }; + + useInput((_input, key) => { + if (key.escape) { + if (streamingRef.current) abortRef.current?.abort(); + else navigate(-1); + return; + } + const view = scrollRef.current; + if (!view) return; + if (key.upArrow) { + const offset = view.getScrollOffset(); + view.scrollBy(-1); + if (offset - 1 < view.getBottomOffset()) stickRef.current = false; + } + if (key.downArrow) { + const offset = view.getScrollOffset(); + view.scrollBy(1); + if (offset + 1 >= view.getBottomOffset()) stickRef.current = true; + } + }); + + return ( + + {detail.isPending ? ( + + ) : detail.isError ? ( + + Error: {detail.error instanceof Error ? detail.error.message : String(detail.error)} + + ) : ( + + + + {items.map((item, index) => ( + + + + ))} + + + + + void run(value)} + prompt="$ " + placeholder="run a command…" + /> + + + {streaming ? ( + + ) : ( + + session: {sessionId} · qualifier: {qualifier} + + )} + + + )} + + ); +} + +function ExecItemView({ item, width }: { item: RuntimeExecItem; width: number }) { + if (item.kind === "exec") { + return ( + + + $ + + {item.command} + + + {item.output !== "" || item.status === "running" ? ( + + + {item.output.trimEnd()} + {item.status === "running" ? "▌" : ""} + + + ) : null} + {item.status === "error" && item.exitCode !== undefined && item.exitCode !== 0 ? ( + + exit {item.exitCode} + + ) : null} + + ); + } + + if (item.kind === "error") { + return ( + + {glyphs.cross} {item.message} + + ); + } + + return ( + + {item.text} + + ); +} diff --git a/src/handlers/runtime/index.tsx b/src/handlers/runtime/index.tsx index 469b6cd074..aad2901369 100644 --- a/src/handlers/runtime/index.tsx +++ b/src/handlers/runtime/index.tsx @@ -6,9 +6,10 @@ import type { Core } from "../types"; import { createRuntimeEndpointHandler } from "./endpoint"; import { createGetRuntimeHandler } from "./get"; import { createInvokeRuntimeHandler } from "./invoke"; +import { createRuntimeShellHandler } from "./shell"; +import { createRuntimeExecHandler } from "./exec"; import { createListRuntimesHandler } from "./list"; import { createRuntimeLogsHandler } from "./logs"; -import { createRuntimeShellHandler } from "./shell"; import { createRuntimeTracesHandler } from "./traces"; import { createRuntimeVersionHandler } from "./version"; @@ -16,10 +17,11 @@ export function createRuntimeHandler(core: Core, io: AppIO): Router { return new Router("runtime", "inspect AgentCore Runtimes") .use(withTuiOnEmptyFlagsAndArgs(core, io)) .default(renderTui(core, io)) - .supportedTuiCommands("get", "list", "invoke", "shell", "version", "endpoint") + .supportedTuiCommands("get", "list", "invoke", "exec", "shell", "version", "endpoint") .handler(createGetRuntimeHandler(core)) .handler(createListRuntimesHandler(core)) .handler(createInvokeRuntimeHandler(core, io)) + .handler(createRuntimeExecHandler(core, io)) .handler(createRuntimeShellHandler(core, io)) .handler(createRuntimeVersionHandler(core, io)) .handler(createRuntimeEndpointHandler(core, io)) diff --git a/src/handlers/runtime/runtime.test.tsx b/src/handlers/runtime/runtime.test.tsx index 23c68ea1ab..6b400c0669 100644 --- a/src/handlers/runtime/runtime.test.tsx +++ b/src/handlers/runtime/runtime.test.tsx @@ -117,6 +117,7 @@ describe("runtime command hierarchy", () => { "get", "list", "invoke", + "exec", "shell", "version", "endpoint", diff --git a/src/handlers/runtime/shell/shell.test.tsx b/src/handlers/runtime/shell/shell.test.tsx index 730a2f10bd..87199f8ed7 100644 --- a/src/handlers/runtime/shell/shell.test.tsx +++ b/src/handlers/runtime/shell/shell.test.tsx @@ -74,15 +74,15 @@ function harness(options: { isTTY?: boolean; runtime?: GetAgentRuntimeResponse } shell, io, run: (...args: string[]) => - root.route(["node", "agentcore", "runtime", "shell", ...args, "--region", REGION]), + root.route(["node", "agentcore", "shell", ...args, "--region", REGION]), }; } -describe("runtime shell command", () => { +describe("shell command", () => { test("opens a direct IAM shell and closes after the remote stream ends", async () => { const subject = harness(); - await subject.run("--id", RUNTIME_ID, "--qualifier", "prod"); + await subject.run("--runtime", RUNTIME_ID, "--qualifier", "prod"); expect(subject.core.runtime.calls.find((call) => call.method === "getRuntime")?.args[0]).toBe( RUNTIME_ID, @@ -114,7 +114,7 @@ describe("runtime shell command", () => { }), }); - await subject.run("--id", RUNTIME_ID, "--qualifier", "DEFAULT", "--bearer-token", "token"); + await subject.run("--runtime", RUNTIME_ID, "--qualifier", "DEFAULT", "--bearer-token", "token"); expect( subject.core.runtime.calls.find((call) => call.method === "openRuntimeShell")?.args[0], @@ -124,7 +124,7 @@ describe("runtime shell command", () => { test("reports whether reconnect preserved the existing shell", async () => { const subject = harness(); - await subject.run("--id", RUNTIME_ID, "--qualifier", "prod"); + await subject.run("--runtime", RUNTIME_ID, "--qualifier", "prod"); const request = subject.core.runtime.calls.find((call) => call.method === "openRuntimeShell") ?.args[0] as RuntimeShellRequest; await request.onReconnect?.(true); @@ -138,7 +138,7 @@ describe("runtime shell command", () => { const subject = harness(); await expect( - subject.run("--id", RUNTIME_ID, "--qualifier", "DEFAULT", "--json"), + subject.run("--runtime", RUNTIME_ID, "--qualifier", "DEFAULT", "--json"), ).rejects.toThrow("--json cannot be used with runtime shell"); expect(subject.core.runtime.calls.some((call) => call.method === "openRuntimeShell")).toBe( false, @@ -148,7 +148,7 @@ describe("runtime shell command", () => { test("requires a TTY for direct shell", async () => { const subject = harness({ isTTY: false }); - await expect(subject.run("--id", RUNTIME_ID, "--qualifier", "DEFAULT")).rejects.toThrow( + await expect(subject.run("--runtime", RUNTIME_ID, "--qualifier", "DEFAULT")).rejects.toThrow( "interactive mode requires a TTY", ); }); @@ -161,7 +161,7 @@ describe("runtime shell command", () => { // shell SDK could not honor it anyway. await expect( subject.run( - "--id", + "--runtime", RUNTIME_ID, "--qualifier", "DEFAULT", diff --git a/src/handlers/shell/index.tsx b/src/handlers/shell/index.tsx new file mode 100644 index 0000000000..90e2815a90 --- /dev/null +++ b/src/handlers/shell/index.tsx @@ -0,0 +1,78 @@ +import z from "zod"; +import { InputValidationError, ResourceNotFoundError } from "../../errors"; +import type { AppIO } from "../../io"; +import { createHandler, flag } from "../../router"; +import { JsonKey, RegionKey } from "../keys"; +import type { Core } from "../types"; +import { renderTuiAt } from "../../tui"; +import { contextForResource, toResourceArn } from "../utils"; +import { regionFromArn, serviceIdFromArn } from "../../core/arn"; +import { RuntimeShellLaunchContextKey } from "../runtime/shell/launchContext"; +import { runRuntimeShell } from "../runtime/shell/operation"; +import { resolveRuntimeShellBearerToken } from "../runtime/shell/request"; + +export const createShellHandler = (core: Core, io: AppIO) => + createHandler({ + name: "shell", + description: "open an interactive shell in a Runtime", + flags: [ + flag("runtime", "the ID of the Runtime", z.string().min(1)), + flag("qualifier", "the endpoint qualifier", z.string().min(1).optional()), + flag("session-id", "the session ID to use", z.string().min(33).max(256).optional()), + flag("bearer-token", "the CUSTOM_JWT bearer token", z.string().optional(), { + sensitive: true, + }), + ], + handle: async (ctx, flags) => { + if (ctx.require(JsonKey)) { + throw new InputValidationError("--json cannot be used with runtime shell"); + } + + const resourceCtx = await contextForResource({ + core, + context: ctx, + resourceType: "runtime", + identifier: flags.runtime, + }); + const resourceArn = await toResourceArn({ + core, + context: resourceCtx, + resourceType: "runtime", + identifier: flags.runtime, + }); + if (resourceArn === undefined) { + throw new ResourceNotFoundError(`Runtime '${flags.runtime}' was not found`); + } + + const resourceRegion = regionFromArn(resourceArn); + const resolvedCtx = resourceRegion + ? resourceCtx.withValue(RegionKey, resourceRegion) + : resourceCtx; + const runtimeId = serviceIdFromArn(resourceArn); + const bearerToken = await resolveRuntimeShellBearerToken(flags["bearer-token"], io.stdin); + const launchContext = { + runtimeId, + runtimeSessionId: flags["session-id"], + bearerToken, + }; + + if (flags.qualifier === undefined) { + await renderTuiAt( + `/agentcore/runtime/shell/${encodeURIComponent(runtimeId)}`, + resolvedCtx.withValue(RuntimeShellLaunchContextKey, launchContext), + core, + io, + ); + return; + } + + await runRuntimeShell({ + ctx: resolvedCtx, + core, + io, + runtimeId, + qualifier: flags.qualifier ?? "DEFAULT", + launchContext, + }); + }, + }); diff --git a/src/handlers/utils.tsx b/src/handlers/utils.tsx index 82d18ae391..a2c35f401c 100644 --- a/src/handlers/utils.tsx +++ b/src/handlers/utils.tsx @@ -1,5 +1,5 @@ import { createContext, useContext, useEffect } from "react"; -import type { Context } from "../router"; +import { ProjectKey, type Context } from "../router"; import type z from "zod"; import type { CoreOptions } from "../core/types"; import type { AppIO } from "../io"; @@ -7,6 +7,8 @@ import { AgentCoreCLIError, InputValidationError, SilentCLIError } from "../erro import { formatZodError } from "../router/schema"; import { AwsCredentialProviderKey, EndpointKey, JsonKey, RegionKey } from "./keys"; import { JsonRendererKey } from "../tui"; +import type { Core } from "./types"; +import { regionFromArn } from "../core/arn"; // coreOptsFromCtx builds the standard CoreOptions handed to Core operations from // the values pinned on the context: the resolved region (always present, see the @@ -22,6 +24,109 @@ export function coreOptsFromCtx(ctx: Context): CoreOptions { }; } +function isNotFound(error: unknown): boolean { + return (error as { name?: string })?.name === "ResourceNotFoundException"; +} + +/** + * Resolve a resource's full ARN from its project name, account ID, or ARN. + * + * @param core injected AgentCore client + * @param context handler context containing region and project information + * @param resourceType resource kind to resolve + * @param identifier project name, account ID, or full ARN + * @param target project deployment target + * @returns the full ARN, or undefined when the resource does not exist + */ +export async function toResourceArn({ + core, + context, + resourceType, + identifier, + target = "default", +}: { + core: Core; + context: Context; + resourceType: "runtime" | "gateway" | "harness"; + identifier: string; + target?: string; +}): Promise { + if (identifier.startsWith("arn:")) return identifier; + + const project = + context.value(ProjectKey) ?? (await core.projectManager.resolve({ filePath: process.cwd() })); + if (project) { + const deploymentTarget = await core.projectManager.resolveTarget(project, { target }); + if (deploymentTarget) { + const resolved = await core.projectManager.resolveProjectResources(project, { + target: deploymentTarget.name, + }); + const resource = resolved.resources.find( + (candidate) => candidate.resourceType === resourceType && candidate.name === identifier, + ); + if (resource?.deploymentState === "deployed" && "arn" in resource) { + return resource.arn; + } + } + } + + try { + const options = coreOptsFromCtx(context); + switch (resourceType) { + case "runtime": + return (await core.runtime.getRuntime(identifier, options)).agentRuntimeArn; + case "gateway": + return (await core.gateway.getGateway(identifier, options)).gatewayArn; + case "harness": + return (await core.harness.getHarness(identifier, options)).harness?.arn; + } + } catch (error) { + if (isNotFound(error)) return undefined; + throw error; + } +} + +/** + * Pin the context used for a project resource to its deployment target. + * + * @param core injected AgentCore client + * @param context handler context + * @param resourceType resource kind to resolve + * @param identifier project name, account ID, or full ARN + * @param target project deployment target + * @returns a context with the target's region and credentials when applicable + */ +export async function contextForResource({ + core, + context, + resourceType, + identifier, + target = "default", +}: { + core: Core; + context: Context; + resourceType: "runtime" | "harness"; + identifier: string; + target?: string; +}): Promise { + const region = regionFromArn(identifier); + if (region) return context.withValue(RegionKey, region); + + const project = + context.value(ProjectKey) ?? (await core.projectManager.resolve({ filePath: process.cwd() })); + const resources = resourceType === "runtime" ? project?.spec.runtimes : project?.spec.harnesses; + if (!resources?.some(({ name }) => name === identifier)) return context; + + const deployed = await core.projectManager.resolveDeployedResource(project!, { + target, + resourceType, + name: identifier, + }); + return context + .withValue(RegionKey, deployed.target.region) + .withValue(AwsCredentialProviderKey, deployed.credentialProvider); +} + // A pinned region replaces RegionKey on every route's context, so a screen that // shows a resource living outside the launch region pins it and everything it // opens next fetches there. The pin lasts until the user navigates back past