From 8113ec3f711025c66fec98876efba249b8fcf188 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Thu, 24 Sep 2026 20:03:39 +0000 Subject: [PATCH 1/8] feat: add top-level exec and shell commands --- README.md | 3 + command.md | 37 +++++++ docs/harness-project-configuration.md | 2 +- scripts/generate-command-reference.mjs | 1 + src/components/CliOnlyScreen.test.tsx | 25 ++--- src/components/RouterScreen.test.tsx | 2 +- src/handlers/exec/index.tsx | 119 ++++++++++++++++++++++ src/handlers/exec/operation.ts | 45 ++++++++ src/handlers/harness/exec/exec.test.tsx | 31 +++--- src/handlers/harness/exec/index.tsx | 81 --------------- src/handlers/harness/index.tsx | 3 - src/handlers/index.tsx | 6 ++ src/handlers/root.test.tsx | 2 + src/handlers/runtime/index.tsx | 4 +- src/handlers/runtime/runtime.test.tsx | 1 - src/handlers/runtime/shell/index.tsx | 55 ---------- src/handlers/runtime/shell/shell.test.tsx | 16 +-- src/handlers/shell/index.tsx | 67 ++++++++++++ src/handlers/utils.tsx | 67 +++++++++++- 19 files changed, 383 insertions(+), 184 deletions(-) create mode 100644 src/handlers/exec/index.tsx create mode 100644 src/handlers/exec/operation.ts delete mode 100644 src/handlers/harness/exec/index.tsx delete mode 100644 src/handlers/runtime/shell/index.tsx create mode 100644 src/handlers/shell/index.tsx 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/command.md b/command.md index 88b03fdb33..bd5d29c4e5 100644 --- a/command.md +++ b/command.md @@ -52,6 +52,9 @@ This reference was generated from `agentcore --help` for version `1.0.0-rc.4`. - [`agentcore traces harness get`](#agentcore-traces-harness-get) - [`agentcore status`](#agentcore-status) - [`agentcore build`](#agentcore-build) +- [Runtime commands](#runtime-commands) + - [`agentcore exec`](#agentcore-exec) + - [`agentcore shell`](#agentcore-shell) - [Evaluation commands](#evaluation-commands) - [`agentcore eval`](#agentcore-eval) - [`agentcore eval evaluator`](#agentcore-eval-evaluator) @@ -868,6 +871,40 @@ agentcore build [options] build the project's deployable artifacts +## Runtime commands + +### `agentcore exec` + +```text +agentcore exec [options] +``` + +run a shell command in a Runtime or harness + +**Options** + +- `--runtime `: the name a Runtime in the project, or ID of a Runtime in the account +- `--harness `: the name of a harness in the project, or ID of a harness in the account +- `--command `: the shell command to run +- `--session-id `: the session ID to run in (33-100 characters) +- `--qualifier `: the endpoint qualifier to run in (default DEFAULT) +- `--timeout `: seconds to wait for the command (1-3600) + +### `agentcore shell` + +```text +agentcore shell [options] +``` + +open an interactive shell in a Runtime + +**Options** + +- `--runtime `: the ID of the Runtime (required) +- `--qualifier `: the endpoint qualifier +- `--session-id `: the session ID to use +- `--bearer-token `: the CUSTOM\_JWT bearer token + ## Evaluation commands ### `agentcore eval` 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/scripts/generate-command-reference.mjs b/scripts/generate-command-reference.mjs index 418271312c..c1bbc7fbd9 100644 --- a/scripts/generate-command-reference.mjs +++ b/scripts/generate-command-reference.mjs @@ -26,6 +26,7 @@ const DEFAULT_GROUPS = [ "build", ], }, + { id: "runtime", title: "Runtime commands", commands: ["exec", "shell"] }, { id: "evaluation", title: "Evaluation commands", commands: ["eval"] }, { id: "settings", diff --git a/src/components/CliOnlyScreen.test.tsx b/src/components/CliOnlyScreen.test.tsx index 66e0ecff06..e9265f8b47 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(); @@ -70,17 +81,7 @@ describe("menus list command-line-only subcommands below a divider", () => { await waitForText(r.lastFrame, "command line only"); expect(menuEntries(r.lastFrame()!)).toEqual({ - screens: [ - "create", - "get", - "list", - "update", - "delete", - "invoke", - "exec", - "endpoint", - "version", - ], + screens: ["create", "get", "list", "update", "delete", "invoke", "endpoint", "version"], cliOnly: ["logs", "traces"], }); r.unmount(); diff --git a/src/components/RouterScreen.test.tsx b/src/components/RouterScreen.test.tsx index 3dd9893295..380d4561c4 100644 --- a/src/components/RouterScreen.test.tsx +++ b/src/components/RouterScreen.test.tsx @@ -54,7 +54,7 @@ describe("menu rendering", () => { 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/exec/index.tsx b/src/handlers/exec/index.tsx new file mode 100644 index 0000000000..e462dc1667 --- /dev/null +++ b/src/handlers/exec/index.tsx @@ -0,0 +1,119 @@ +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, coreOptsFromCtx, toResourceArn } from "../utils"; +import { JsonRendererKey, renderTuiAt } from "../../tui"; +import { serviceIdFromArn } from "../../core/arn"; +import { RuntimeShellLaunchContextKey } from "../runtime/shell/launchContext"; +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) { + if (flags.command !== undefined || ctx.require(JsonKey)) { + throw new InputValidationError("specify one of --runtime or --harness"); + } + await renderTuiAt("/agentcore", ctx, core, io); + return; + } + + const resourceArn = await toResourceArn({ + core, + context: ctx, + resourceType, + identifier, + }); + if (resourceArn === undefined) { + throw new ResourceNotFoundError( + `${resourceType === "runtime" ? "Runtime" : "Harness"} '${identifier}' was not found`, + ); + } + + const resourceId = serviceIdFromArn(resourceArn); + if (flags.command === undefined) { + if (ctx.require(JsonKey)) { + throw new InputValidationError("required option '--command ' not specified"); + } + if (resourceType === "harness") { + let path = `/agentcore/harness/exec/${encodeURIComponent(resourceId)}`; + if (flags["session-id"]) path += `/${encodeURIComponent(flags["session-id"])}`; + if (flags.qualifier) path += `?qualifier=${encodeURIComponent(flags.qualifier)}`; + await renderTuiAt(path, ctx, core, io); + } else { + const path = [ + "/agentcore/runtime/shell", + encodeURIComponent(resourceId), + flags.qualifier === undefined ? undefined : encodeURIComponent(flags.qualifier), + ] + .filter((part): part is string => part !== undefined) + .join("/"); + await renderTuiAt( + path, + ctx.withValue(RuntimeShellLaunchContextKey, { + runtimeId: resourceId, + runtimeSessionId: flags["session-id"], + }), + core, + io, + ); + } + return; + } + + const result = await invokeExecCommand( + core, + { + resourceArn, + command: flags.command, + runtimeSessionId: flags["session-id"], + qualifier: flags.qualifier ?? "DEFAULT", + timeout: flags.timeout, + }, + coreOptsFromCtx(ctx), + ); + 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..e9f4fa5eb9 --- /dev/null +++ b/src/handlers/exec/operation.ts @@ -0,0 +1,45 @@ +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: 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/exec.test.tsx b/src/handlers/harness/exec/exec.test.tsx index 801f9ee734..4c34837e9b 100644 --- a/src/handlers/harness/exec/exec.test.tsx +++ b/src/handlers/harness/exec/exec.test.tsx @@ -15,7 +15,7 @@ import { 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/harness/exec/index.tsx b/src/handlers/harness/exec/index.tsx deleted file mode 100644 index bcae691ebe..0000000000 --- a/src/handlers/harness/exec/index.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import z from "zod"; -import { createHandler, flag, PathKey } from "../../../router"; -import type { AppIO } from "../../../io"; -import type { Core } from "../../types.tsx"; -import { coreOptsFromCtx } from "../../utils.tsx"; -import { JsonKey } from "../../keys.tsx"; -import { JsonRendererKey, renderTuiAt } from "../../../tui"; -import { InputValidationError } from "../../../errors"; -import { applyExecEvent, finishExec, newExecItem } from "../invoke/transcript.tsx"; - -export const createExecHarnessHandler = (core: Core, io: AppIO) => - createHandler({ - name: "exec", - description: "run a shell command in a harness", - flags: [ - flag("id", "the ID of the harness", z.string().min(1).max(48)), - 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 harness 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) => { - // 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"); - } - let path = `${ctx.require(PathKey)}/${flags["id"]}`; - if (flags["session-id"]) path += `/${flags["session-id"]}`; - if (flags["qualifier"]) path += `?qualifier=${encodeURIComponent(flags["qualifier"])}`; - await renderTuiAt(path, ctx, core, io); - return; - } - - 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"], - body: { command: flags["command"], timeout: flags["timeout"] }, - }, - opts, - ); - - const item = newExecItem(flags["command"]); - for await (const event of response.stream ?? []) { - applyExecEvent(item, event); - } - finishExec(item); - - ctx.require(JsonRendererKey).renderJson({ - sessionId: flags["session-id"] ?? response.runtimeSessionId, - command: item.command, - exitCode: item.exitCode, - status: item.status, - output: item.output, - }); - }, - }); - -export { HarnessExecScreen } from "./screen.tsx"; diff --git a/src/handlers/harness/index.tsx b/src/handlers/harness/index.tsx index 9c29e98968..c7f04739cb 100644 --- a/src/handlers/harness/index.tsx +++ b/src/handlers/harness/index.tsx @@ -9,7 +9,6 @@ import { createCreateHarnessHandler } from "./create"; import { createUpdateHarnessHandler } from "./update"; import { createDeleteHarnessHandler } from "./delete"; import { createInvokeHarnessHandler } from "./invoke"; -import { createExecHarnessHandler } from "./exec"; import { createEndpointHandler } from "./endpoint"; import { createVersionHandler } from "./version"; import { createHarnessLogsHandler } from "./logs"; @@ -29,7 +28,6 @@ export function createHarnessHandler(core: Core, io: AppIO): Router { "update", "delete", "invoke", - "exec", "endpoint", "version", ); @@ -41,7 +39,6 @@ export function createHarnessHandler(core: Core, io: AppIO): Router { harness.handler(createUpdateHarnessHandler(core)); harness.handler(createDeleteHarnessHandler(core)); harness.handler(createInvokeHarnessHandler(core, io)); - harness.handler(createExecHarnessHandler(core, io)); harness.handler(createHarnessLogsHandler(core, io)); harness.handler(createHarnessTracesHandler(core, io)); 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/index.tsx b/src/handlers/runtime/index.tsx index 469b6cd074..4fdb1a9829 100644 --- a/src/handlers/runtime/index.tsx +++ b/src/handlers/runtime/index.tsx @@ -8,7 +8,6 @@ import { createGetRuntimeHandler } from "./get"; import { createInvokeRuntimeHandler } from "./invoke"; import { createListRuntimesHandler } from "./list"; import { createRuntimeLogsHandler } from "./logs"; -import { createRuntimeShellHandler } from "./shell"; import { createRuntimeTracesHandler } from "./traces"; import { createRuntimeVersionHandler } from "./version"; @@ -16,11 +15,10 @@ 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", "version", "endpoint") .handler(createGetRuntimeHandler(core)) .handler(createListRuntimesHandler(core)) .handler(createInvokeRuntimeHandler(core, io)) - .handler(createRuntimeShellHandler(core, io)) .handler(createRuntimeVersionHandler(core, io)) .handler(createRuntimeEndpointHandler(core, io)) .handler(createRuntimeLogsHandler(core, io)) diff --git a/src/handlers/runtime/runtime.test.tsx b/src/handlers/runtime/runtime.test.tsx index 23c68ea1ab..714fd99f76 100644 --- a/src/handlers/runtime/runtime.test.tsx +++ b/src/handlers/runtime/runtime.test.tsx @@ -117,7 +117,6 @@ describe("runtime command hierarchy", () => { "get", "list", "invoke", - "shell", "version", "endpoint", "logs", diff --git a/src/handlers/runtime/shell/index.tsx b/src/handlers/runtime/shell/index.tsx deleted file mode 100644 index 2ac2375cd9..0000000000 --- a/src/handlers/runtime/shell/index.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import z from "zod"; -import { InputValidationError } from "../../../errors"; -import type { AppIO } from "../../../io"; -import { createHandler, flag, PathKey } from "../../../router"; -import { renderTuiAt } from "../../../tui"; -import { JsonKey } from "../../keys"; -import type { Core } from "../../types"; -import { runtimeIdSchema } from "../invoke/request"; -import { RuntimeShellLaunchContextKey } from "./launchContext"; -import { runRuntimeShell } from "./operation"; -import { resolveRuntimeShellBearerToken } from "./request"; - -export const createRuntimeShellHandler = (core: Core, io: AppIO) => - createHandler({ - name: "shell", - description: "open an interactive shell in a Runtime", - flags: [ - flag("id", "the ID of the Runtime", runtimeIdSchema), - flag("qualifier", "the Runtime endpoint qualifier", z.string().min(1).optional()), - flag("session-id", "the Runtime 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 bearerToken = await resolveRuntimeShellBearerToken(flags["bearer-token"], io.stdin); - const launchContext = { - runtimeId: flags.id, - runtimeSessionId: flags["session-id"], - bearerToken, - }; - if (flags.qualifier === undefined) { - await renderTuiAt( - `${ctx.require(PathKey)}/${encodeURIComponent(flags.id)}`, - ctx.withValue(RuntimeShellLaunchContextKey, launchContext), - core, - io, - ); - return; - } - await runRuntimeShell({ - ctx, - core, - io, - runtimeId: flags.id, - qualifier: flags.qualifier, - launchContext, - }); - }, - }); - -export { RuntimeShellScreen } from "./screen"; 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..410ecdd965 --- /dev/null +++ b/src/handlers/shell/index.tsx @@ -0,0 +1,67 @@ +import z from "zod"; +import { InputValidationError, ResourceNotFoundError } from "../../errors"; +import type { AppIO } from "../../io"; +import { createHandler, flag } from "../../router"; +import { renderTuiAt } from "../../tui"; +import { JsonKey } from "../keys"; +import type { Core } from "../types"; +import { toResourceArn } from "../utils"; +import { serviceIdFromArn } from "../../core/arn"; +import { RuntimeShellLaunchContextKey } from "../runtime/shell/launchContext"; +import { runRuntimeShell } from "../runtime/shell/operation"; + +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 resourceArn = await toResourceArn({ + core, + context: ctx, + resourceType: "runtime", + identifier: flags.runtime, + }); + if (resourceArn === undefined) { + throw new ResourceNotFoundError(`Runtime '${flags.runtime}' was not found`); + } + + const runtimeId = serviceIdFromArn(resourceArn); + const bearerToken = flags["bearer-token"]; + const launchContext = { + runtimeId, + runtimeSessionId: flags["session-id"], + bearerToken, + }; + + if (flags.qualifier === undefined) { + await renderTuiAt( + `/agentcore/runtime/shell/${encodeURIComponent(runtimeId)}`, + ctx.withValue(RuntimeShellLaunchContextKey, launchContext), + core, + io, + ); + return; + } + + await runRuntimeShell({ + ctx, + core, + io, + runtimeId, + qualifier: flags.qualifier, + launchContext, + }); + }, + }); diff --git a/src/handlers/utils.tsx b/src/handlers/utils.tsx index 82d18ae391..d41d5bb9c3 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,16 @@ 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"; + +export type ResourceType = "runtime" | "gateway" | "harness"; + +export interface ToResourceArnInput { + core: Core; + context: Context; + resourceType: ResourceType; + identifier: string; +} // 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 +32,61 @@ export function coreOptsFromCtx(ctx: Context): CoreOptions { }; } +function isNotFound(error: unknown): boolean { + return (error as { name?: string })?.name === "ResourceNotFoundException"; +} + +/** + * Given a resource type and identifier, resolve the resource's full ARN. + * + * An identifier may be a logical name in the current AgentCore project, a + * service ID in the authenticated account, or an ARN. Project names are + * resolved from the default deployment target; non-project identifiers are + * looked up directly in the corresponding control-plane API. + * + * Returns undefined when the resource is not deployed or does not exist. + */ +export async function toResourceArn({ + core, + context, + resourceType, + identifier, +}: ToResourceArnInput): Promise { + if (identifier.startsWith("arn:")) return identifier; + + const project = + context.value(ProjectKey) ?? (await core.projectManager.resolve({ filePath: process.cwd() })); + if (project) { + const target = await core.projectManager.resolveTarget(project, { target: "default" }); + if (target) { + const resolved = await core.projectManager.resolveProjectResources(project, { + target: target.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; + } +} + // 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 From dc85a8e2a5e5db36a2fa514c3fa2c45d59006712 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Thu, 24 Sep 2026 20:47:13 +0000 Subject: [PATCH 2/8] fix: address exec and shell review feedback --- src/components/Root.tsx | 2 ++ src/handlers/exec/index.tsx | 45 +++++++++++---------------- src/handlers/exec/operation.ts | 17 ++++++---- src/handlers/harness/exec/screen.tsx | 29 ++++++++++++++--- src/handlers/runtime/shell/screen.tsx | 40 +++++++++++++++++++----- src/handlers/shell/index.tsx | 3 +- src/handlers/shell/screen.tsx | 31 ++++++++++++++++++ src/handlers/utils.tsx | 27 +++++++--------- 8 files changed, 133 insertions(+), 61 deletions(-) create mode 100644 src/handlers/shell/screen.tsx diff --git a/src/components/Root.tsx b/src/components/Root.tsx index f86b1b93d6..4acfdf0fe3 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 { ShellScreen } from "../handlers/shell/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"; @@ -302,6 +303,7 @@ function RouteTable({ ctx, core }: ScreenProps) { path="agentcore/harness/exec/:harnessId/:sessionId" element={} /> + } /> } diff --git a/src/handlers/exec/index.tsx b/src/handlers/exec/index.tsx index e462dc1667..87325dfba0 100644 --- a/src/handlers/exec/index.tsx +++ b/src/handlers/exec/index.tsx @@ -77,43 +77,34 @@ export const createExecHandler = (core: Core, io: AppIO) => if (ctx.require(JsonKey)) { throw new InputValidationError("required option '--command ' not specified"); } - if (resourceType === "harness") { - let path = `/agentcore/harness/exec/${encodeURIComponent(resourceId)}`; - if (flags["session-id"]) path += `/${encodeURIComponent(flags["session-id"])}`; - if (flags.qualifier) path += `?qualifier=${encodeURIComponent(flags.qualifier)}`; - await renderTuiAt(path, ctx, core, io); - } else { - const path = [ - "/agentcore/runtime/shell", - encodeURIComponent(resourceId), - flags.qualifier === undefined ? undefined : encodeURIComponent(flags.qualifier), - ] - .filter((part): part is string => part !== undefined) - .join("/"); - await renderTuiAt( - path, - ctx.withValue(RuntimeShellLaunchContextKey, { - runtimeId: resourceId, - runtimeSessionId: flags["session-id"], - }), - core, - io, - ); - } + const params = new URLSearchParams({ resourceType }); + if (flags["session-id"]) params.set("sessionId", flags["session-id"]); + if (flags.qualifier) params.set("qualifier", flags.qualifier); + await renderTuiAt( + `/agentcore/shell/${encodeURIComponent(resourceId)}?${params}`, + resourceType === "runtime" + ? ctx.withValue(RuntimeShellLaunchContextKey, { + runtimeId: resourceId, + runtimeSessionId: flags["session-id"], + }) + : ctx, + core, + io, + ); return; } - const result = await invokeExecCommand( + const result = await invokeExecCommand({ core, - { + input: { resourceArn, command: flags.command, runtimeSessionId: flags["session-id"], qualifier: flags.qualifier ?? "DEFAULT", timeout: flags.timeout, }, - coreOptsFromCtx(ctx), - ); + options: coreOptsFromCtx(ctx), + }); ctx.require(JsonRendererKey).renderJson(result); }, }); diff --git a/src/handlers/exec/operation.ts b/src/handlers/exec/operation.ts index e9f4fa5eb9..b52b41e3a0 100644 --- a/src/handlers/exec/operation.ts +++ b/src/handlers/exec/operation.ts @@ -19,12 +19,17 @@ export type ExecResult = { output: string; }; -export async function invokeExecCommand( - core: Core, - input: ExecInput, - options: CoreOptions, - signal?: AbortSignal, -): Promise { +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, diff --git a/src/handlers/harness/exec/screen.tsx b/src/handlers/harness/exec/screen.tsx index 6130b2ee86..38860d0063 100644 --- a/src/handlers/harness/exec/screen.tsx +++ b/src/handlers/harness/exec/screen.tsx @@ -8,18 +8,39 @@ import { HarnessChat } from "../invoke/screen"; // 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(); +type HarnessExecScreenProps = ScreenProps & { + harnessId?: string; + sessionId?: string; + routePath?: string; + resourceType?: "harness"; +}; + +export function HarnessExecScreen({ + harnessId: routeHarnessId, + sessionId: routeSessionId, + routePath = "/agentcore/harness/exec", + resourceType, + ...props +}: HarnessExecScreenProps) { + const params = useParams(); const [search] = useSearchParams(); const navigate = useNavigate(); + const harnessId = routeHarnessId ?? params.harnessId; + const sessionId = routeSessionId ?? params.sessionId ?? search.get("sessionId") ?? undefined; if (!harnessId) { return ( navigate(`/agentcore/harness/exec/${id}`)} + onSelect={(id) => + navigate( + resourceType + ? `${routePath}/${encodeURIComponent(id)}?resourceType=${resourceType}` + : `${routePath}/${encodeURIComponent(id)}`, + ) + } /> ); } diff --git a/src/handlers/runtime/shell/screen.tsx b/src/handlers/runtime/shell/screen.tsx index 6687b3c463..1231a804cc 100644 --- a/src/handlers/runtime/shell/screen.tsx +++ b/src/handlers/runtime/shell/screen.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from "react"; import { useApp, useStderr, useStdin, useStdout } from "ink"; -import { useLocation, useNavigate, useParams } from "react-router"; +import { useLocation, useNavigate, useParams, useSearchParams } from "react-router"; import { RuntimeEndpointPicker } from "../../../components/RuntimeEndpointPicker"; import { RuntimePicker } from "../../../components/RuntimePicker"; import { Spinner } from "../../../components/ui/spinner"; @@ -17,21 +17,45 @@ type RuntimeShellLocationState = { const shellPath = (...parts: string[]) => ["/agentcore/runtime/shell", ...parts.map(encodeURIComponent)].join("/"); -export function RuntimeShellScreen(props: ScreenProps) { - const { runtimeId, qualifier } = useParams(); +type RuntimeShellScreenProps = ScreenProps & { + runtimeId?: string; + qualifier?: string; + routePath?: string; + resourceType?: "runtime"; +}; + +export function RuntimeShellScreen({ + runtimeId: routeRuntimeId, + qualifier: routeQualifier, + routePath = "/agentcore/runtime/shell", + resourceType, + ...props +}: RuntimeShellScreenProps) { + const params = useParams(); + const [search] = useSearchParams(); + const runtimeId = routeRuntimeId ?? params.runtimeId; + const qualifier = params.qualifier ?? routeQualifier ?? search.get("qualifier") ?? undefined; const location = useLocation(); const navigate = useNavigate(); const locationState = location.state as RuntimeShellLocationState | null; const returnOnEscape = locationState?.returnOnEscape; + const route = (id?: string, endpoint?: string) => { + if (routePath === "/agentcore/runtime/shell") { + return id === undefined ? shellPath() : shellPath(id, ...(endpoint ? [endpoint] : [])); + } + const params = new URLSearchParams({ resourceType: resourceType ?? "runtime" }); + if (endpoint) params.set("qualifier", endpoint); + return `${routePath}${id === undefined ? "" : `/${encodeURIComponent(id)}`}?${params}`; + }; if (!runtimeId) { return ( - navigate(shellPath(id), { + navigate(route(id), { state: { returnPath: locationState?.returnPath ?? location.pathname }, }) } @@ -48,10 +72,10 @@ export function RuntimeShellScreen(props: ScreenProps) { - navigate(shellPath(runtimeId, selected), { + navigate(route(runtimeId, selected), { replace: returnOnEscape === true, state: { ...locationState, @@ -59,7 +83,7 @@ export function RuntimeShellScreen(props: ScreenProps) { }, }) } - onEscape={() => (returnOnEscape ? navigate(-1) : navigate(shellPath()))} + onEscape={() => (returnOnEscape ? navigate(-1) : navigate(route()))} /> ); } diff --git a/src/handlers/shell/index.tsx b/src/handlers/shell/index.tsx index 410ecdd965..45259061eb 100644 --- a/src/handlers/shell/index.tsx +++ b/src/handlers/shell/index.tsx @@ -46,8 +46,9 @@ export const createShellHandler = (core: Core, io: AppIO) => }; if (flags.qualifier === undefined) { + const params = new URLSearchParams({ resourceType: "runtime" }); await renderTuiAt( - `/agentcore/runtime/shell/${encodeURIComponent(runtimeId)}`, + `/agentcore/shell/${encodeURIComponent(runtimeId)}?${params}`, ctx.withValue(RuntimeShellLaunchContextKey, launchContext), core, io, diff --git a/src/handlers/shell/screen.tsx b/src/handlers/shell/screen.tsx new file mode 100644 index 0000000000..906392be97 --- /dev/null +++ b/src/handlers/shell/screen.tsx @@ -0,0 +1,31 @@ +import { useParams, useSearchParams } from "react-router"; +import { HarnessExecScreen } from "../harness/exec/screen"; +import { RuntimeShellScreen } from "../runtime/shell/screen"; +import type { ScreenProps } from "../types"; + +export function ShellScreen(props: ScreenProps) { + const { resourceId } = useParams(); + const [search] = useSearchParams(); + + if (search.get("resourceType") === "harness") { + return ( + + ); + } + + return ( + + ); +} diff --git a/src/handlers/utils.tsx b/src/handlers/utils.tsx index d41d5bb9c3..26e51279b6 100644 --- a/src/handlers/utils.tsx +++ b/src/handlers/utils.tsx @@ -11,13 +11,6 @@ import type { Core } from "./types"; export type ResourceType = "runtime" | "gateway" | "harness"; -export interface ToResourceArnInput { - core: Core; - context: Context; - resourceType: ResourceType; - identifier: string; -} - // coreOptsFromCtx builds the standard CoreOptions handed to Core operations from // the values pinned on the context: the resolved region (always present, see the // withRegion middleware), the optional --endpoint-url override, and any explicit @@ -37,21 +30,25 @@ function isNotFound(error: unknown): boolean { } /** - * Given a resource type and identifier, resolve the resource's full ARN. + * Resolve a resource's full ARN from its project name, account ID, or ARN. * - * An identifier may be a logical name in the current AgentCore project, a - * service ID in the authenticated account, or an ARN. Project names are - * resolved from the default deployment target; non-project identifiers are - * looked up directly in the corresponding control-plane API. - * - * Returns undefined when the resource is not deployed or does not exist. + * @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 + * @returns the full ARN, or undefined when the resource does not exist */ export async function toResourceArn({ core, context, resourceType, identifier, -}: ToResourceArnInput): Promise { +}: { + core: Core; + context: Context; + resourceType: ResourceType; + identifier: string; +}): Promise { if (identifier.startsWith("arn:")) return identifier; const project = From e6bb43cc5fedae9e34db90967d108ef90bce7f7c Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Thu, 24 Sep 2026 21:03:44 +0000 Subject: [PATCH 3/8] fix: simplify exec and shell routing --- command.md | 37 ------------ scripts/generate-command-reference.mjs | 1 - src/components/Root.tsx | 15 ++--- .../{harness => }/exec/exec.screen.test.tsx | 6 +- src/handlers/{harness => }/exec/exec.test.tsx | 8 +-- src/handlers/exec/index.tsx | 28 +++------- src/handlers/exec/screen.tsx | 35 ++++++++++++ src/handlers/harness/exec/screen.tsx | 56 ------------------- src/handlers/harness/get/screen.tsx | 2 +- src/handlers/runtime/shell/screen.tsx | 40 +++---------- src/handlers/shell/index.tsx | 15 +---- src/handlers/shell/screen.tsx | 31 ---------- 12 files changed, 65 insertions(+), 209 deletions(-) rename src/handlers/{harness => }/exec/exec.screen.test.tsx (98%) rename src/handlers/{harness => }/exec/exec.test.tsx (95%) create mode 100644 src/handlers/exec/screen.tsx delete mode 100644 src/handlers/harness/exec/screen.tsx delete mode 100644 src/handlers/shell/screen.tsx diff --git a/command.md b/command.md index bd5d29c4e5..88b03fdb33 100644 --- a/command.md +++ b/command.md @@ -52,9 +52,6 @@ This reference was generated from `agentcore --help` for version `1.0.0-rc.4`. - [`agentcore traces harness get`](#agentcore-traces-harness-get) - [`agentcore status`](#agentcore-status) - [`agentcore build`](#agentcore-build) -- [Runtime commands](#runtime-commands) - - [`agentcore exec`](#agentcore-exec) - - [`agentcore shell`](#agentcore-shell) - [Evaluation commands](#evaluation-commands) - [`agentcore eval`](#agentcore-eval) - [`agentcore eval evaluator`](#agentcore-eval-evaluator) @@ -871,40 +868,6 @@ agentcore build [options] build the project's deployable artifacts -## Runtime commands - -### `agentcore exec` - -```text -agentcore exec [options] -``` - -run a shell command in a Runtime or harness - -**Options** - -- `--runtime `: the name a Runtime in the project, or ID of a Runtime in the account -- `--harness `: the name of a harness in the project, or ID of a harness in the account -- `--command `: the shell command to run -- `--session-id `: the session ID to run in (33-100 characters) -- `--qualifier `: the endpoint qualifier to run in (default DEFAULT) -- `--timeout `: seconds to wait for the command (1-3600) - -### `agentcore shell` - -```text -agentcore shell [options] -``` - -open an interactive shell in a Runtime - -**Options** - -- `--runtime `: the ID of the Runtime (required) -- `--qualifier `: the endpoint qualifier -- `--session-id `: the session ID to use -- `--bearer-token `: the CUSTOM\_JWT bearer token - ## Evaluation commands ### `agentcore eval` diff --git a/scripts/generate-command-reference.mjs b/scripts/generate-command-reference.mjs index c1bbc7fbd9..418271312c 100644 --- a/scripts/generate-command-reference.mjs +++ b/scripts/generate-command-reference.mjs @@ -26,7 +26,6 @@ const DEFAULT_GROUPS = [ "build", ], }, - { id: "runtime", title: "Runtime commands", commands: ["exec", "shell"] }, { id: "evaluation", title: "Evaluation commands", commands: ["eval"] }, { id: "settings", diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 4acfdf0fe3..311cdf7dc5 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -16,7 +16,7 @@ import { HarnessCreateScreen } from "../handlers/harness/create/screen.tsx"; import { HarnessUpdateScreen } from "../handlers/harness/update/screen.tsx"; import { HarnessDeleteScreen } from "../handlers/harness/delete/screen.tsx"; import { HarnessInvokeScreen } from "../handlers/harness/invoke/screen.tsx"; -import { HarnessExecScreen } from "../handlers/harness/exec/screen.tsx"; +import { ExecScreen } from "../handlers/exec/screen.tsx"; import { HarnessEndpointScreen } from "../handlers/harness/endpoint/screen.tsx"; import { HarnessCreateEndpointScreen } from "../handlers/harness/endpoint/create/screen.tsx"; import { HarnessGetEndpointScreen } from "../handlers/harness/endpoint/get/screen.tsx"; @@ -43,7 +43,6 @@ 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 { ShellScreen } from "../handlers/shell/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"; @@ -294,16 +293,12 @@ function RouteTable({ ctx, core }: ScreenProps) { path="agentcore/harness/invoke/:harnessId/:sessionId" element={} /> - } /> + } /> + } /> } + path="agentcore/exec/:harnessId/:sessionId" + element={} /> - } - /> - } /> } diff --git a/src/handlers/harness/exec/exec.screen.test.tsx b/src/handlers/exec/exec.screen.test.tsx similarity index 98% rename from src/handlers/harness/exec/exec.screen.test.tsx rename to src/handlers/exec/exec.screen.test.tsx index 94bed2947c..9d35bb6b5b 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); @@ -22,7 +22,7 @@ afterEach(cleanupScreens); // shows their output inline in the same transcript as chat turns. const ARN = "arn:aws:bedrock-agentcore:us-east-1:123:harness/MyHarness-abc123"; -const EXEC_PATH = "/agentcore/harness/exec/MyHarness-abc123"; +const EXEC_PATH = "/agentcore/exec/MyHarness-abc123"; const CTRL_E = "\x05"; function summary(): HarnessSummary { @@ -66,7 +66,7 @@ describe("exec screen", () => { test("the exec route opens a picker, and selection lands in exec mode", async () => { const core = execCore(); core.harness.setListResponse({ harnesses: [summary()] }); - const r = renderImperativeScreen("/agentcore/harness/exec", { core }); + const r = renderImperativeScreen("/agentcore/exec", { core }); await waitForText(r.lastFrame, "choose a harness to exec into"); await waitForText(r.lastFrame, "MyHarness"); diff --git a/src/handlers/harness/exec/exec.test.tsx b/src/handlers/exec/exec.test.tsx similarity index 95% rename from src/handlers/harness/exec/exec.test.tsx rename to src/handlers/exec/exec.test.tsx index 4c34837e9b..93cb0f099f 100644 --- a/src/handlers/harness/exec/exec.test.tsx +++ b/src/handlers/exec/exec.test.tsx @@ -4,16 +4,16 @@ 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 top-level `exec --harness`, driven through the real root handler. // Like the invoke suite, these use a TestCoreClient because the command diff --git a/src/handlers/exec/index.tsx b/src/handlers/exec/index.tsx index 87325dfba0..27fa1e4e74 100644 --- a/src/handlers/exec/index.tsx +++ b/src/handlers/exec/index.tsx @@ -7,7 +7,6 @@ import type { Core } from "../types"; import { assertMutuallyExclusiveFlags, coreOptsFromCtx, toResourceArn } from "../utils"; import { JsonRendererKey, renderTuiAt } from "../../tui"; import { serviceIdFromArn } from "../../core/arn"; -import { RuntimeShellLaunchContextKey } from "../runtime/shell/launchContext"; import { invokeExecCommand } from "./operation"; export const createExecHandler = (core: Core, io: AppIO) => @@ -53,11 +52,7 @@ export const createExecHandler = (core: Core, io: AppIO) => : undefined; const identifier = flags.runtime ?? flags.harness; if (resourceType === undefined || identifier === undefined) { - if (flags.command !== undefined || ctx.require(JsonKey)) { - throw new InputValidationError("specify one of --runtime or --harness"); - } - await renderTuiAt("/agentcore", ctx, core, io); - return; + throw new InputValidationError("specify one of --runtime or --harness"); } const resourceArn = await toResourceArn({ @@ -77,20 +72,13 @@ export const createExecHandler = (core: Core, io: AppIO) => if (ctx.require(JsonKey)) { throw new InputValidationError("required option '--command ' not specified"); } - const params = new URLSearchParams({ resourceType }); - if (flags["session-id"]) params.set("sessionId", flags["session-id"]); - if (flags.qualifier) params.set("qualifier", flags.qualifier); - await renderTuiAt( - `/agentcore/shell/${encodeURIComponent(resourceId)}?${params}`, - resourceType === "runtime" - ? ctx.withValue(RuntimeShellLaunchContextKey, { - runtimeId: resourceId, - runtimeSessionId: flags["session-id"], - }) - : ctx, - core, - io, - ); + if (resourceType !== "harness") { + throw new InputValidationError("required option '--command ' not specified"); + } + let path = `/agentcore/exec/${encodeURIComponent(resourceId)}`; + if (flags["session-id"]) path += `/${encodeURIComponent(flags["session-id"])}`; + if (flags.qualifier) path += `?qualifier=${encodeURIComponent(flags.qualifier)}`; + await renderTuiAt(path, ctx, core, io); return; } diff --git a/src/handlers/exec/screen.tsx b/src/handlers/exec/screen.tsx new file mode 100644 index 0000000000..c2758e9e42 --- /dev/null +++ b/src/handlers/exec/screen.tsx @@ -0,0 +1,35 @@ +import { useNavigate, useParams, useSearchParams } from "react-router"; +import type { ScreenProps } from "../types"; +import { HarnessPicker } from "../../components/HarnessPicker"; +import { HarnessChat } from "../harness/invoke/screen"; + +// ExecScreen is `agentcore exec --harness` 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 ExecScreen(props: ScreenProps) { + const { harnessId, sessionId } = useParams(); + const [search] = useSearchParams(); + const navigate = useNavigate(); + + if (!harnessId) { + return ( + navigate(`/agentcore/exec/${encodeURIComponent(id)}`)} + /> + ); + } + return ( + + ); +} diff --git a/src/handlers/harness/exec/screen.tsx b/src/handlers/harness/exec/screen.tsx deleted file mode 100644 index 38860d0063..0000000000 --- a/src/handlers/harness/exec/screen.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { useNavigate, useParams, useSearchParams } from "react-router"; -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. -type HarnessExecScreenProps = ScreenProps & { - harnessId?: string; - sessionId?: string; - routePath?: string; - resourceType?: "harness"; -}; - -export function HarnessExecScreen({ - harnessId: routeHarnessId, - sessionId: routeSessionId, - routePath = "/agentcore/harness/exec", - resourceType, - ...props -}: HarnessExecScreenProps) { - const params = useParams(); - const [search] = useSearchParams(); - const navigate = useNavigate(); - const harnessId = routeHarnessId ?? params.harnessId; - const sessionId = routeSessionId ?? params.sessionId ?? search.get("sessionId") ?? undefined; - - if (!harnessId) { - return ( - - navigate( - resourceType - ? `${routePath}/${encodeURIComponent(id)}?resourceType=${resourceType}` - : `${routePath}/${encodeURIComponent(id)}`, - ) - } - /> - ); - } - return ( - - ); -} diff --git a/src/handlers/harness/get/screen.tsx b/src/handlers/harness/get/screen.tsx index e50378b0bd..c8ec4f7052 100644 --- a/src/handlers/harness/get/screen.tsx +++ b/src/handlers/harness/get/screen.tsx @@ -46,7 +46,7 @@ const ACTIONS: { name: string; description: string; to: (id: string) => string } { name: "exec", description: "run shell commands in this harness", - to: (id) => `/agentcore/harness/exec/${id}`, + to: (id) => `/agentcore/exec/${id}`, }, { name: "update", diff --git a/src/handlers/runtime/shell/screen.tsx b/src/handlers/runtime/shell/screen.tsx index 1231a804cc..6687b3c463 100644 --- a/src/handlers/runtime/shell/screen.tsx +++ b/src/handlers/runtime/shell/screen.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from "react"; import { useApp, useStderr, useStdin, useStdout } from "ink"; -import { useLocation, useNavigate, useParams, useSearchParams } from "react-router"; +import { useLocation, useNavigate, useParams } from "react-router"; import { RuntimeEndpointPicker } from "../../../components/RuntimeEndpointPicker"; import { RuntimePicker } from "../../../components/RuntimePicker"; import { Spinner } from "../../../components/ui/spinner"; @@ -17,45 +17,21 @@ type RuntimeShellLocationState = { const shellPath = (...parts: string[]) => ["/agentcore/runtime/shell", ...parts.map(encodeURIComponent)].join("/"); -type RuntimeShellScreenProps = ScreenProps & { - runtimeId?: string; - qualifier?: string; - routePath?: string; - resourceType?: "runtime"; -}; - -export function RuntimeShellScreen({ - runtimeId: routeRuntimeId, - qualifier: routeQualifier, - routePath = "/agentcore/runtime/shell", - resourceType, - ...props -}: RuntimeShellScreenProps) { - const params = useParams(); - const [search] = useSearchParams(); - const runtimeId = routeRuntimeId ?? params.runtimeId; - const qualifier = params.qualifier ?? routeQualifier ?? search.get("qualifier") ?? undefined; +export function RuntimeShellScreen(props: ScreenProps) { + const { runtimeId, qualifier } = useParams(); const location = useLocation(); const navigate = useNavigate(); const locationState = location.state as RuntimeShellLocationState | null; const returnOnEscape = locationState?.returnOnEscape; - const route = (id?: string, endpoint?: string) => { - if (routePath === "/agentcore/runtime/shell") { - return id === undefined ? shellPath() : shellPath(id, ...(endpoint ? [endpoint] : [])); - } - const params = new URLSearchParams({ resourceType: resourceType ?? "runtime" }); - if (endpoint) params.set("qualifier", endpoint); - return `${routePath}${id === undefined ? "" : `/${encodeURIComponent(id)}`}?${params}`; - }; if (!runtimeId) { return ( - navigate(route(id), { + navigate(shellPath(id), { state: { returnPath: locationState?.returnPath ?? location.pathname }, }) } @@ -72,10 +48,10 @@ export function RuntimeShellScreen({ - navigate(route(runtimeId, selected), { + navigate(shellPath(runtimeId, selected), { replace: returnOnEscape === true, state: { ...locationState, @@ -83,7 +59,7 @@ export function RuntimeShellScreen({ }, }) } - onEscape={() => (returnOnEscape ? navigate(-1) : navigate(route()))} + onEscape={() => (returnOnEscape ? navigate(-1) : navigate(shellPath()))} /> ); } diff --git a/src/handlers/shell/index.tsx b/src/handlers/shell/index.tsx index 45259061eb..1c0d5f3a87 100644 --- a/src/handlers/shell/index.tsx +++ b/src/handlers/shell/index.tsx @@ -2,12 +2,10 @@ import z from "zod"; import { InputValidationError, ResourceNotFoundError } from "../../errors"; import type { AppIO } from "../../io"; import { createHandler, flag } from "../../router"; -import { renderTuiAt } from "../../tui"; import { JsonKey } from "../keys"; import type { Core } from "../types"; import { toResourceArn } from "../utils"; import { serviceIdFromArn } from "../../core/arn"; -import { RuntimeShellLaunchContextKey } from "../runtime/shell/launchContext"; import { runRuntimeShell } from "../runtime/shell/operation"; export const createShellHandler = (core: Core, io: AppIO) => @@ -45,23 +43,12 @@ export const createShellHandler = (core: Core, io: AppIO) => bearerToken, }; - if (flags.qualifier === undefined) { - const params = new URLSearchParams({ resourceType: "runtime" }); - await renderTuiAt( - `/agentcore/shell/${encodeURIComponent(runtimeId)}?${params}`, - ctx.withValue(RuntimeShellLaunchContextKey, launchContext), - core, - io, - ); - return; - } - await runRuntimeShell({ ctx, core, io, runtimeId, - qualifier: flags.qualifier, + qualifier: flags.qualifier ?? "DEFAULT", launchContext, }); }, diff --git a/src/handlers/shell/screen.tsx b/src/handlers/shell/screen.tsx deleted file mode 100644 index 906392be97..0000000000 --- a/src/handlers/shell/screen.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { useParams, useSearchParams } from "react-router"; -import { HarnessExecScreen } from "../harness/exec/screen"; -import { RuntimeShellScreen } from "../runtime/shell/screen"; -import type { ScreenProps } from "../types"; - -export function ShellScreen(props: ScreenProps) { - const { resourceId } = useParams(); - const [search] = useSearchParams(); - - if (search.get("resourceType") === "harness") { - return ( - - ); - } - - return ( - - ); -} From 74c4ef7c9b0049c1ef25c3f275dabfad594d3aea Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Thu, 24 Sep 2026 21:14:54 +0000 Subject: [PATCH 4/8] fix: support runtime exec routing --- src/components/Root.tsx | 7 +---- src/handlers/exec/exec.screen.test.tsx | 6 ++-- src/handlers/exec/index.tsx | 22 +++++++++----- src/handlers/exec/screen.tsx | 38 ++++++++++++++++++------ src/handlers/harness/get/screen.tsx | 2 +- src/handlers/runtime/shell/screen.tsx | 40 ++++++++++++++++++++------ src/handlers/utils.tsx | 4 +-- 7 files changed, 82 insertions(+), 37 deletions(-) diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 311cdf7dc5..c0dc5adbf6 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -293,12 +293,7 @@ function RouteTable({ ctx, core }: ScreenProps) { path="agentcore/harness/invoke/:harnessId/:sessionId" element={} /> - } /> - } /> - } - /> + } /> } diff --git a/src/handlers/exec/exec.screen.test.tsx b/src/handlers/exec/exec.screen.test.tsx index 9d35bb6b5b..0e6b510623 100644 --- a/src/handlers/exec/exec.screen.test.tsx +++ b/src/handlers/exec/exec.screen.test.tsx @@ -22,7 +22,7 @@ afterEach(cleanupScreens); // shows their output inline in the same transcript as chat turns. const ARN = "arn:aws:bedrock-agentcore:us-east-1:123:harness/MyHarness-abc123"; -const EXEC_PATH = "/agentcore/exec/MyHarness-abc123"; +const EXEC_PATH = "/agentcore/exec/MyHarness-abc123?resourceType=harness"; const CTRL_E = "\x05"; function summary(): HarnessSummary { @@ -66,7 +66,7 @@ describe("exec screen", () => { test("the exec route opens a picker, and selection lands in exec mode", async () => { const core = execCore(); core.harness.setListResponse({ harnesses: [summary()] }); - const r = renderImperativeScreen("/agentcore/exec", { core }); + const r = renderImperativeScreen("/agentcore/exec?resourceType=harness", { core }); await waitForText(r.lastFrame, "choose a harness to exec into"); await waitForText(r.lastFrame, "MyHarness"); @@ -103,7 +103,7 @@ describe("exec screen", () => { test("a session id in the route resumes that session", async () => { const resumed = "resumed-session-0123456789abcdefghijklmn"; // 33+ chars const core = execCore(); - const r = renderImperativeScreen(`${EXEC_PATH}/${resumed}`, { core }); + const r = renderImperativeScreen(`${EXEC_PATH}&sessionId=${resumed}`, { core }); await waitForText(r.lastFrame, `session: ${resumed}`); await type(r, "pwd"); diff --git a/src/handlers/exec/index.tsx b/src/handlers/exec/index.tsx index 27fa1e4e74..0a9094a7b6 100644 --- a/src/handlers/exec/index.tsx +++ b/src/handlers/exec/index.tsx @@ -7,6 +7,7 @@ import type { Core } from "../types"; import { assertMutuallyExclusiveFlags, coreOptsFromCtx, toResourceArn } from "../utils"; import { JsonRendererKey, renderTuiAt } from "../../tui"; import { serviceIdFromArn } from "../../core/arn"; +import { RuntimeShellLaunchContextKey } from "../runtime/shell/launchContext"; import { invokeExecCommand } from "./operation"; export const createExecHandler = (core: Core, io: AppIO) => @@ -72,13 +73,20 @@ export const createExecHandler = (core: Core, io: AppIO) => if (ctx.require(JsonKey)) { throw new InputValidationError("required option '--command ' not specified"); } - if (resourceType !== "harness") { - throw new InputValidationError("required option '--command ' not specified"); - } - let path = `/agentcore/exec/${encodeURIComponent(resourceId)}`; - if (flags["session-id"]) path += `/${encodeURIComponent(flags["session-id"])}`; - if (flags.qualifier) path += `?qualifier=${encodeURIComponent(flags.qualifier)}`; - await renderTuiAt(path, ctx, core, io); + const params = new URLSearchParams({ resourceType }); + if (flags["session-id"]) params.set("sessionId", flags["session-id"]); + if (flags.qualifier) params.set("qualifier", flags.qualifier); + await renderTuiAt( + `/agentcore/exec/${encodeURIComponent(resourceId)}?${params}`, + resourceType === "runtime" + ? ctx.withValue(RuntimeShellLaunchContextKey, { + runtimeId: resourceId, + runtimeSessionId: flags["session-id"], + }) + : ctx, + core, + io, + ); return; } diff --git a/src/handlers/exec/screen.tsx b/src/handlers/exec/screen.tsx index c2758e9e42..4bda5f4f47 100644 --- a/src/handlers/exec/screen.tsx +++ b/src/handlers/exec/screen.tsx @@ -2,31 +2,51 @@ import { useNavigate, useParams, useSearchParams } from "react-router"; import type { ScreenProps } from "../types"; import { HarnessPicker } from "../../components/HarnessPicker"; import { HarnessChat } from "../harness/invoke/screen"; +import { RuntimeShellScreen } from "../runtime/shell/screen"; -// ExecScreen is `agentcore exec --harness` 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. +// ExecScreen dispatches to the existing Runtime shell or Harness exec screen +// based on the resourceType query parameter. export function ExecScreen(props: ScreenProps) { - const { harnessId, sessionId } = useParams(); + const { resourceId } = useParams(); + const [search] = useSearchParams(); + + if (search.get("resourceType") === "runtime") { + return ( + + ); + } + + return ; +} + +// Harness exec uses the shared harness chat screen, starting in exec mode. +function HarnessExecScreen({ resourceId, ...props }: ScreenProps & { resourceId?: string }) { const [search] = useSearchParams(); const navigate = useNavigate(); + const sessionId = search.get("sessionId") ?? undefined; - if (!harnessId) { + if (!resourceId) { return ( navigate(`/agentcore/exec/${encodeURIComponent(id)}`)} + onSelect={(id) => + navigate(`/agentcore/exec/${encodeURIComponent(id)}?resourceType=harness`) + } /> ); } return ( string } { name: "exec", description: "run shell commands in this harness", - to: (id) => `/agentcore/exec/${id}`, + to: (id) => `/agentcore/exec/${id}?resourceType=harness`, }, { name: "update", diff --git a/src/handlers/runtime/shell/screen.tsx b/src/handlers/runtime/shell/screen.tsx index 6687b3c463..79e80f8e93 100644 --- a/src/handlers/runtime/shell/screen.tsx +++ b/src/handlers/runtime/shell/screen.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from "react"; import { useApp, useStderr, useStdin, useStdout } from "ink"; -import { useLocation, useNavigate, useParams } from "react-router"; +import { useLocation, useNavigate, useParams, useSearchParams } from "react-router"; import { RuntimeEndpointPicker } from "../../../components/RuntimeEndpointPicker"; import { RuntimePicker } from "../../../components/RuntimePicker"; import { Spinner } from "../../../components/ui/spinner"; @@ -17,21 +17,45 @@ type RuntimeShellLocationState = { const shellPath = (...parts: string[]) => ["/agentcore/runtime/shell", ...parts.map(encodeURIComponent)].join("/"); -export function RuntimeShellScreen(props: ScreenProps) { - const { runtimeId, qualifier } = useParams(); +type RuntimeShellScreenProps = ScreenProps & { + runtimeId?: string; + qualifier?: string; + routePath?: string; + resourceType?: "runtime"; +}; + +export function RuntimeShellScreen({ + runtimeId: routeRuntimeId, + qualifier: routeQualifier, + routePath = "/agentcore/runtime/shell", + resourceType, + ...props +}: RuntimeShellScreenProps) { + const { runtimeId: paramRuntimeId, qualifier: paramQualifier } = useParams(); + const [search] = useSearchParams(); + const runtimeId = routeRuntimeId ?? paramRuntimeId; + const qualifier = paramQualifier ?? routeQualifier ?? search.get("qualifier") ?? undefined; const location = useLocation(); const navigate = useNavigate(); const locationState = location.state as RuntimeShellLocationState | null; const returnOnEscape = locationState?.returnOnEscape; + const route = (id?: string, endpoint?: string) => { + if (routePath === "/agentcore/runtime/shell") { + return id === undefined ? shellPath() : shellPath(id, ...(endpoint ? [endpoint] : [])); + } + const params = new URLSearchParams({ resourceType: resourceType ?? "runtime" }); + if (endpoint) params.set("qualifier", endpoint); + return `${routePath}${id === undefined ? "" : `/${encodeURIComponent(id)}`}?${params}`; + }; if (!runtimeId) { return ( - navigate(shellPath(id), { + navigate(route(id), { state: { returnPath: locationState?.returnPath ?? location.pathname }, }) } @@ -48,10 +72,10 @@ export function RuntimeShellScreen(props: ScreenProps) { - navigate(shellPath(runtimeId, selected), { + navigate(route(runtimeId, selected), { replace: returnOnEscape === true, state: { ...locationState, @@ -59,7 +83,7 @@ export function RuntimeShellScreen(props: ScreenProps) { }, }) } - onEscape={() => (returnOnEscape ? navigate(-1) : navigate(shellPath()))} + onEscape={() => (returnOnEscape ? navigate(-1) : navigate(route()))} /> ); } diff --git a/src/handlers/utils.tsx b/src/handlers/utils.tsx index 26e51279b6..0e4780d410 100644 --- a/src/handlers/utils.tsx +++ b/src/handlers/utils.tsx @@ -9,8 +9,6 @@ import { AwsCredentialProviderKey, EndpointKey, JsonKey, RegionKey } from "./key import { JsonRendererKey } from "../tui"; import type { Core } from "./types"; -export type ResourceType = "runtime" | "gateway" | "harness"; - // coreOptsFromCtx builds the standard CoreOptions handed to Core operations from // the values pinned on the context: the resolved region (always present, see the // withRegion middleware), the optional --endpoint-url override, and any explicit @@ -46,7 +44,7 @@ export async function toResourceArn({ }: { core: Core; context: Context; - resourceType: ResourceType; + resourceType: "runtime" | "gateway" | "harness"; identifier: string; }): Promise { if (identifier.startsWith("arn:")) return identifier; From 9ecc9dcf5ceb35004f2a905e2bbb70dd924ac4fd Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Thu, 24 Sep 2026 21:21:40 +0000 Subject: [PATCH 5/8] fix: use explicit exec route parameters --- src/components/Root.tsx | 10 +++++++++- src/handlers/exec/exec.screen.test.tsx | 6 +++--- src/handlers/exec/index.tsx | 8 ++++---- src/handlers/exec/screen.tsx | 20 ++++++++++---------- src/handlers/harness/get/screen.tsx | 2 +- src/handlers/runtime/shell/screen.tsx | 7 ++----- 6 files changed, 29 insertions(+), 24 deletions(-) diff --git a/src/components/Root.tsx b/src/components/Root.tsx index c0dc5adbf6..7c10a5966e 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -293,7 +293,15 @@ function RouteTable({ ctx, core }: ScreenProps) { path="agentcore/harness/invoke/:harnessId/:sessionId" element={} /> - } /> + } /> + } + /> + } + /> } diff --git a/src/handlers/exec/exec.screen.test.tsx b/src/handlers/exec/exec.screen.test.tsx index 0e6b510623..320372df66 100644 --- a/src/handlers/exec/exec.screen.test.tsx +++ b/src/handlers/exec/exec.screen.test.tsx @@ -22,7 +22,7 @@ afterEach(cleanupScreens); // shows their output inline in the same transcript as chat turns. const ARN = "arn:aws:bedrock-agentcore:us-east-1:123:harness/MyHarness-abc123"; -const EXEC_PATH = "/agentcore/exec/MyHarness-abc123?resourceType=harness"; +const EXEC_PATH = "/agentcore/exec/harness/MyHarness-abc123"; const CTRL_E = "\x05"; function summary(): HarnessSummary { @@ -66,7 +66,7 @@ describe("exec screen", () => { test("the exec route opens a picker, and selection lands in exec mode", async () => { const core = execCore(); core.harness.setListResponse({ harnesses: [summary()] }); - const r = renderImperativeScreen("/agentcore/exec?resourceType=harness", { core }); + const r = renderImperativeScreen("/agentcore/exec/harness", { core }); await waitForText(r.lastFrame, "choose a harness to exec into"); await waitForText(r.lastFrame, "MyHarness"); @@ -103,7 +103,7 @@ describe("exec screen", () => { test("a session id in the route resumes that session", async () => { const resumed = "resumed-session-0123456789abcdefghijklmn"; // 33+ chars const core = execCore(); - const r = renderImperativeScreen(`${EXEC_PATH}&sessionId=${resumed}`, { core }); + const r = renderImperativeScreen(`${EXEC_PATH}/${resumed}`, { core }); await waitForText(r.lastFrame, `session: ${resumed}`); await type(r, "pwd"); diff --git a/src/handlers/exec/index.tsx b/src/handlers/exec/index.tsx index 0a9094a7b6..866ea924a7 100644 --- a/src/handlers/exec/index.tsx +++ b/src/handlers/exec/index.tsx @@ -73,11 +73,11 @@ export const createExecHandler = (core: Core, io: AppIO) => if (ctx.require(JsonKey)) { throw new InputValidationError("required option '--command ' not specified"); } - const params = new URLSearchParams({ resourceType }); - if (flags["session-id"]) params.set("sessionId", flags["session-id"]); - if (flags.qualifier) params.set("qualifier", flags.qualifier); + let path = `/agentcore/exec/${resourceType}/${encodeURIComponent(resourceId)}`; + if (flags["session-id"]) path += `/${encodeURIComponent(flags["session-id"])}`; + if (flags.qualifier) path += `?qualifier=${encodeURIComponent(flags.qualifier)}`; await renderTuiAt( - `/agentcore/exec/${encodeURIComponent(resourceId)}?${params}`, + path, resourceType === "runtime" ? ctx.withValue(RuntimeShellLaunchContextKey, { runtimeId: resourceId, diff --git a/src/handlers/exec/screen.tsx b/src/handlers/exec/screen.tsx index 4bda5f4f47..4d50dd59c9 100644 --- a/src/handlers/exec/screen.tsx +++ b/src/handlers/exec/screen.tsx @@ -7,29 +7,31 @@ import { RuntimeShellScreen } from "../runtime/shell/screen"; // ExecScreen dispatches to the existing Runtime shell or Harness exec screen // based on the resourceType query parameter. export function ExecScreen(props: ScreenProps) { - const { resourceId } = useParams(); + const { resourceType, resourceId, sessionId } = useParams(); const [search] = useSearchParams(); - if (search.get("resourceType") === "runtime") { + if (resourceType === "runtime") { return ( ); } - return ; + return ; } // Harness exec uses the shared harness chat screen, starting in exec mode. -function HarnessExecScreen({ resourceId, ...props }: ScreenProps & { resourceId?: string }) { +function HarnessExecScreen({ + resourceId, + sessionId, + ...props +}: ScreenProps & { resourceId?: string; sessionId?: string }) { const [search] = useSearchParams(); const navigate = useNavigate(); - const sessionId = search.get("sessionId") ?? undefined; if (!resourceId) { return ( @@ -37,9 +39,7 @@ function HarnessExecScreen({ resourceId, ...props }: ScreenProps & { resourceId? {...props} breadcrumb={["agentcore", "exec"]} description="choose a harness to exec into" - onSelect={(id) => - navigate(`/agentcore/exec/${encodeURIComponent(id)}?resourceType=harness`) - } + onSelect={(id) => navigate(`/agentcore/exec/harness/${encodeURIComponent(id)}`)} /> ); } diff --git a/src/handlers/harness/get/screen.tsx b/src/handlers/harness/get/screen.tsx index 7bced87e4b..36b9cf52ae 100644 --- a/src/handlers/harness/get/screen.tsx +++ b/src/handlers/harness/get/screen.tsx @@ -46,7 +46,7 @@ const ACTIONS: { name: string; description: string; to: (id: string) => string } { name: "exec", description: "run shell commands in this harness", - to: (id) => `/agentcore/exec/${id}?resourceType=harness`, + to: (id) => `/agentcore/exec/harness/${id}`, }, { name: "update", diff --git a/src/handlers/runtime/shell/screen.tsx b/src/handlers/runtime/shell/screen.tsx index 79e80f8e93..a22b768999 100644 --- a/src/handlers/runtime/shell/screen.tsx +++ b/src/handlers/runtime/shell/screen.tsx @@ -21,14 +21,12 @@ type RuntimeShellScreenProps = ScreenProps & { runtimeId?: string; qualifier?: string; routePath?: string; - resourceType?: "runtime"; }; export function RuntimeShellScreen({ runtimeId: routeRuntimeId, qualifier: routeQualifier, routePath = "/agentcore/runtime/shell", - resourceType, ...props }: RuntimeShellScreenProps) { const { runtimeId: paramRuntimeId, qualifier: paramQualifier } = useParams(); @@ -43,9 +41,8 @@ export function RuntimeShellScreen({ if (routePath === "/agentcore/runtime/shell") { return id === undefined ? shellPath() : shellPath(id, ...(endpoint ? [endpoint] : [])); } - const params = new URLSearchParams({ resourceType: resourceType ?? "runtime" }); - if (endpoint) params.set("qualifier", endpoint); - return `${routePath}${id === undefined ? "" : `/${encodeURIComponent(id)}`}?${params}`; + const path = `${routePath}${id === undefined ? "" : `/${encodeURIComponent(id)}`}`; + return endpoint ? `${path}?qualifier=${encodeURIComponent(endpoint)}` : path; }; if (!runtimeId) { From f733fb2bbdf1886e16e0af7e8f2a884d1383d549 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Thu, 24 Sep 2026 21:37:44 +0000 Subject: [PATCH 6/8] fix: add dedicated runtime exec screen --- src/handlers/exec/index.tsx | 42 ++-- src/handlers/exec/runtime/screen.tsx | 281 ++++++++++++++++++++++++++ src/handlers/exec/screen.tsx | 16 +- src/handlers/runtime/shell/screen.tsx | 37 +--- src/handlers/shell/index.tsx | 20 +- src/handlers/utils.tsx | 51 ++++- 6 files changed, 381 insertions(+), 66 deletions(-) create mode 100644 src/handlers/exec/runtime/screen.tsx diff --git a/src/handlers/exec/index.tsx b/src/handlers/exec/index.tsx index 866ea924a7..6e23a70f06 100644 --- a/src/handlers/exec/index.tsx +++ b/src/handlers/exec/index.tsx @@ -4,10 +4,15 @@ import { createHandler, flag } from "../../router"; import { JsonKey } from "../keys"; import type { AppIO } from "../../io"; import type { Core } from "../types"; -import { assertMutuallyExclusiveFlags, coreOptsFromCtx, toResourceArn } from "../utils"; +import { + assertMutuallyExclusiveFlags, + contextForResource, + coreOptsFromCtx, + toResourceArn, +} from "../utils"; import { JsonRendererKey, renderTuiAt } from "../../tui"; -import { serviceIdFromArn } from "../../core/arn"; -import { RuntimeShellLaunchContextKey } from "../runtime/shell/launchContext"; +import { regionFromArn, serviceIdFromArn } from "../../core/arn"; +import { RegionKey } from "../keys"; import { invokeExecCommand } from "./operation"; export const createExecHandler = (core: Core, io: AppIO) => @@ -56,18 +61,28 @@ export const createExecHandler = (core: Core, io: AppIO) => throw new InputValidationError("specify one of --runtime or --harness"); } - const resourceArn = await toResourceArn({ + 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)) { @@ -75,18 +90,11 @@ export const createExecHandler = (core: Core, io: AppIO) => } let path = `/agentcore/exec/${resourceType}/${encodeURIComponent(resourceId)}`; if (flags["session-id"]) path += `/${encodeURIComponent(flags["session-id"])}`; - if (flags.qualifier) path += `?qualifier=${encodeURIComponent(flags.qualifier)}`; - await renderTuiAt( - path, - resourceType === "runtime" - ? ctx.withValue(RuntimeShellLaunchContextKey, { - runtimeId: resourceId, - runtimeSessionId: flags["session-id"], - }) - : ctx, - core, - io, - ); + 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; } @@ -99,7 +107,7 @@ export const createExecHandler = (core: Core, io: AppIO) => qualifier: flags.qualifier ?? "DEFAULT", timeout: flags.timeout, }, - options: coreOptsFromCtx(ctx), + options: coreOptsFromCtx(resolvedCtx), }); ctx.require(JsonRendererKey).renderJson(result); }, diff --git a/src/handlers/exec/runtime/screen.tsx b/src/handlers/exec/runtime/screen.tsx new file mode 100644 index 0000000000..47306ebf78 --- /dev/null +++ b/src/handlers/exec/runtime/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/exec/runtime"]; + if (runtimeId !== undefined) parts.push(encodeURIComponent(runtimeId)); + if (sessionId !== undefined) parts.push(encodeURIComponent(sessionId)); + return parts.join("/"); +}; + +export function RuntimeExecScreen(props: ScreenProps) { + const { resourceId, 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 (!resourceId) { + 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/exec/screen.tsx b/src/handlers/exec/screen.tsx index 4d50dd59c9..61485b6077 100644 --- a/src/handlers/exec/screen.tsx +++ b/src/handlers/exec/screen.tsx @@ -2,23 +2,15 @@ import { useNavigate, useParams, useSearchParams } from "react-router"; import type { ScreenProps } from "../types"; import { HarnessPicker } from "../../components/HarnessPicker"; import { HarnessChat } from "../harness/invoke/screen"; -import { RuntimeShellScreen } from "../runtime/shell/screen"; +import { RuntimeExecScreen } from "./runtime/screen"; -// ExecScreen dispatches to the existing Runtime shell or Harness exec screen -// based on the resourceType query parameter. +// ExecScreen dispatches to the Runtime exec or Harness exec screen based on +// the resourceType route parameter. export function ExecScreen(props: ScreenProps) { const { resourceType, resourceId, sessionId } = useParams(); - const [search] = useSearchParams(); if (resourceType === "runtime") { - return ( - - ); + return ; } return ; diff --git a/src/handlers/runtime/shell/screen.tsx b/src/handlers/runtime/shell/screen.tsx index a22b768999..6687b3c463 100644 --- a/src/handlers/runtime/shell/screen.tsx +++ b/src/handlers/runtime/shell/screen.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from "react"; import { useApp, useStderr, useStdin, useStdout } from "ink"; -import { useLocation, useNavigate, useParams, useSearchParams } from "react-router"; +import { useLocation, useNavigate, useParams } from "react-router"; import { RuntimeEndpointPicker } from "../../../components/RuntimeEndpointPicker"; import { RuntimePicker } from "../../../components/RuntimePicker"; import { Spinner } from "../../../components/ui/spinner"; @@ -17,42 +17,21 @@ type RuntimeShellLocationState = { const shellPath = (...parts: string[]) => ["/agentcore/runtime/shell", ...parts.map(encodeURIComponent)].join("/"); -type RuntimeShellScreenProps = ScreenProps & { - runtimeId?: string; - qualifier?: string; - routePath?: string; -}; - -export function RuntimeShellScreen({ - runtimeId: routeRuntimeId, - qualifier: routeQualifier, - routePath = "/agentcore/runtime/shell", - ...props -}: RuntimeShellScreenProps) { - const { runtimeId: paramRuntimeId, qualifier: paramQualifier } = useParams(); - const [search] = useSearchParams(); - const runtimeId = routeRuntimeId ?? paramRuntimeId; - const qualifier = paramQualifier ?? routeQualifier ?? search.get("qualifier") ?? undefined; +export function RuntimeShellScreen(props: ScreenProps) { + const { runtimeId, qualifier } = useParams(); const location = useLocation(); const navigate = useNavigate(); const locationState = location.state as RuntimeShellLocationState | null; const returnOnEscape = locationState?.returnOnEscape; - const route = (id?: string, endpoint?: string) => { - if (routePath === "/agentcore/runtime/shell") { - return id === undefined ? shellPath() : shellPath(id, ...(endpoint ? [endpoint] : [])); - } - const path = `${routePath}${id === undefined ? "" : `/${encodeURIComponent(id)}`}`; - return endpoint ? `${path}?qualifier=${encodeURIComponent(endpoint)}` : path; - }; if (!runtimeId) { return ( - navigate(route(id), { + navigate(shellPath(id), { state: { returnPath: locationState?.returnPath ?? location.pathname }, }) } @@ -69,10 +48,10 @@ export function RuntimeShellScreen({ - navigate(route(runtimeId, selected), { + navigate(shellPath(runtimeId, selected), { replace: returnOnEscape === true, state: { ...locationState, @@ -80,7 +59,7 @@ export function RuntimeShellScreen({ }, }) } - onEscape={() => (returnOnEscape ? navigate(-1) : navigate(route()))} + onEscape={() => (returnOnEscape ? navigate(-1) : navigate(shellPath()))} /> ); } diff --git a/src/handlers/shell/index.tsx b/src/handlers/shell/index.tsx index 1c0d5f3a87..2af7ef3bf9 100644 --- a/src/handlers/shell/index.tsx +++ b/src/handlers/shell/index.tsx @@ -2,10 +2,10 @@ import z from "zod"; import { InputValidationError, ResourceNotFoundError } from "../../errors"; import type { AppIO } from "../../io"; import { createHandler, flag } from "../../router"; -import { JsonKey } from "../keys"; +import { JsonKey, RegionKey } from "../keys"; import type { Core } from "../types"; -import { toResourceArn } from "../utils"; -import { serviceIdFromArn } from "../../core/arn"; +import { contextForResource, toResourceArn } from "../utils"; +import { regionFromArn, serviceIdFromArn } from "../../core/arn"; import { runRuntimeShell } from "../runtime/shell/operation"; export const createShellHandler = (core: Core, io: AppIO) => @@ -25,16 +25,26 @@ export const createShellHandler = (core: Core, io: AppIO) => throw new InputValidationError("--json cannot be used with runtime shell"); } - const resourceArn = await toResourceArn({ + 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 = flags["bearer-token"]; const launchContext = { @@ -44,7 +54,7 @@ export const createShellHandler = (core: Core, io: AppIO) => }; await runRuntimeShell({ - ctx, + ctx: resolvedCtx, core, io, runtimeId, diff --git a/src/handlers/utils.tsx b/src/handlers/utils.tsx index 0e4780d410..a2c35f401c 100644 --- a/src/handlers/utils.tsx +++ b/src/handlers/utils.tsx @@ -8,6 +8,7 @@ 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 @@ -34,6 +35,7 @@ function isNotFound(error: unknown): boolean { * @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({ @@ -41,21 +43,23 @@ export async function toResourceArn({ 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 target = await core.projectManager.resolveTarget(project, { target: "default" }); - if (target) { + const deploymentTarget = await core.projectManager.resolveTarget(project, { target }); + if (deploymentTarget) { const resolved = await core.projectManager.resolveProjectResources(project, { - target: target.name, + target: deploymentTarget.name, }); const resource = resolved.resources.find( (candidate) => candidate.resourceType === resourceType && candidate.name === identifier, @@ -82,6 +86,47 @@ export async function toResourceArn({ } } +/** + * 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 From 01b8d2e7a04435683df71cb2f3c880e27166c6b7 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 25 Sep 2026 17:01:17 +0000 Subject: [PATCH 7/8] fix: preserve resource exec and shell routes --- src/components/Root.tsx | 22 ++++-- src/handlers/exec/exec.screen.test.tsx | 4 +- src/handlers/exec/index.tsx | 2 +- src/handlers/exec/screen.tsx | 47 ------------ src/handlers/harness/exec/index.tsx | 72 +++++++++++++++++++ src/handlers/harness/exec/screen.tsx | 30 ++++++++ src/handlers/harness/get/screen.tsx | 2 +- src/handlers/harness/index.tsx | 3 + src/handlers/runtime/exec/index.tsx | 66 +++++++++++++++++ .../{exec/runtime => runtime/exec}/screen.tsx | 12 ++-- src/handlers/runtime/index.tsx | 6 +- src/handlers/runtime/shell/index.tsx | 55 ++++++++++++++ src/handlers/shell/index.tsx | 15 +++- 13 files changed, 271 insertions(+), 65 deletions(-) delete mode 100644 src/handlers/exec/screen.tsx create mode 100644 src/handlers/harness/exec/index.tsx create mode 100644 src/handlers/harness/exec/screen.tsx create mode 100644 src/handlers/runtime/exec/index.tsx rename src/handlers/{exec/runtime => runtime/exec}/screen.tsx (96%) create mode 100644 src/handlers/runtime/shell/index.tsx diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 7c10a5966e..61654b4a5f 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -16,7 +16,7 @@ import { HarnessCreateScreen } from "../handlers/harness/create/screen.tsx"; import { HarnessUpdateScreen } from "../handlers/harness/update/screen.tsx"; import { HarnessDeleteScreen } from "../handlers/harness/delete/screen.tsx"; import { HarnessInvokeScreen } from "../handlers/harness/invoke/screen.tsx"; -import { ExecScreen } from "../handlers/exec/screen.tsx"; +import { HarnessExecScreen } from "../handlers/harness/exec/screen.tsx"; import { HarnessEndpointScreen } from "../handlers/harness/endpoint/screen.tsx"; import { HarnessCreateEndpointScreen } from "../handlers/harness/endpoint/create/screen.tsx"; import { HarnessGetEndpointScreen } from "../handlers/harness/endpoint/get/screen.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"; @@ -293,14 +294,14 @@ function RouteTable({ ctx, core }: ScreenProps) { path="agentcore/harness/invoke/:harnessId/:sessionId" element={} /> - } /> + } /> } + path="agentcore/harness/exec/:harnessId" + element={} /> } + path="agentcore/harness/exec/:harnessId/:sessionId" + element={} /> } /> + } /> + } + /> + } + /> } /> { test("the exec route opens a picker, and selection lands in exec mode", async () => { const core = execCore(); core.harness.setListResponse({ harnesses: [summary()] }); - const r = renderImperativeScreen("/agentcore/exec/harness", { core }); + const r = renderImperativeScreen("/agentcore/harness/exec", { core }); await waitForText(r.lastFrame, "choose a harness to exec into"); await waitForText(r.lastFrame, "MyHarness"); diff --git a/src/handlers/exec/index.tsx b/src/handlers/exec/index.tsx index 6e23a70f06..738b2a10d5 100644 --- a/src/handlers/exec/index.tsx +++ b/src/handlers/exec/index.tsx @@ -88,7 +88,7 @@ export const createExecHandler = (core: Core, io: AppIO) => if (ctx.require(JsonKey)) { throw new InputValidationError("required option '--command ' not specified"); } - let path = `/agentcore/exec/${resourceType}/${encodeURIComponent(resourceId)}`; + 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); diff --git a/src/handlers/exec/screen.tsx b/src/handlers/exec/screen.tsx deleted file mode 100644 index 61485b6077..0000000000 --- a/src/handlers/exec/screen.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useNavigate, useParams, useSearchParams } from "react-router"; -import type { ScreenProps } from "../types"; -import { HarnessPicker } from "../../components/HarnessPicker"; -import { HarnessChat } from "../harness/invoke/screen"; -import { RuntimeExecScreen } from "./runtime/screen"; - -// ExecScreen dispatches to the Runtime exec or Harness exec screen based on -// the resourceType route parameter. -export function ExecScreen(props: ScreenProps) { - const { resourceType, resourceId, sessionId } = useParams(); - - if (resourceType === "runtime") { - return ; - } - - return ; -} - -// Harness exec uses the shared harness chat screen, starting in exec mode. -function HarnessExecScreen({ - resourceId, - sessionId, - ...props -}: ScreenProps & { resourceId?: string; sessionId?: string }) { - const [search] = useSearchParams(); - const navigate = useNavigate(); - - if (!resourceId) { - return ( - navigate(`/agentcore/exec/harness/${encodeURIComponent(id)}`)} - /> - ); - } - return ( - - ); -} diff --git a/src/handlers/harness/exec/index.tsx b/src/handlers/harness/exec/index.tsx new file mode 100644 index 0000000000..b9d07e24e3 --- /dev/null +++ b/src/handlers/harness/exec/index.tsx @@ -0,0 +1,72 @@ +import z from "zod"; +import { createHandler, flag, PathKey } from "../../../router"; +import type { AppIO } from "../../../io"; +import type { Core } from "../../types.tsx"; +import { coreOptsFromCtx } from "../../utils.tsx"; +import { JsonKey } from "../../keys.tsx"; +import { JsonRendererKey, renderTuiAt } from "../../../tui"; +import { InputValidationError } from "../../../errors"; +import { applyExecEvent, finishExec, newExecItem } from "../invoke/transcript.tsx"; + +export const createExecHarnessHandler = (core: Core, io: AppIO) => + createHandler({ + name: "exec", + description: "run a shell command in a harness", + flags: [ + flag("id", "the ID of the harness", z.string().min(1).max(48)), + 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 harness 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)}/${flags["id"]}`; + if (flags["session-id"]) path += `/${flags["session-id"]}`; + if (flags["qualifier"]) path += `?qualifier=${encodeURIComponent(flags["qualifier"])}`; + await renderTuiAt(path, ctx, core, io); + return; + } + + const opts = coreOptsFromCtx(ctx); + const detail = await core.harness.getHarness(flags["id"], opts); + const response = await core.harness.invokeAgentRuntimeCommand( + { + agentRuntimeArn: detail.harness?.arn, + qualifier: flags["qualifier"] ?? "DEFAULT", + runtimeSessionId: flags["session-id"], + body: { command: flags["command"], timeout: flags["timeout"] }, + }, + opts, + ); + + const item = newExecItem(flags["command"]); + for await (const event of response.stream ?? []) applyExecEvent(item, event); + finishExec(item); + + ctx.require(JsonRendererKey).renderJson({ + sessionId: flags["session-id"] ?? response.runtimeSessionId, + command: item.command, + exitCode: item.exitCode, + status: item.status, + output: item.output, + }); + }, + }); + +export { HarnessExecScreen } from "./screen.tsx"; diff --git a/src/handlers/harness/exec/screen.tsx b/src/handlers/harness/exec/screen.tsx new file mode 100644 index 0000000000..437c698d85 --- /dev/null +++ b/src/handlers/harness/exec/screen.tsx @@ -0,0 +1,30 @@ +import { useNavigate, useParams, useSearchParams } from "react-router"; +import type { ScreenProps } from "../../types"; +import { HarnessPicker } from "../../../components/HarnessPicker"; +import { HarnessChat } from "../invoke/screen"; + +export function HarnessExecScreen(props: ScreenProps) { + const { harnessId, sessionId } = useParams(); + const [search] = useSearchParams(); + const navigate = useNavigate(); + + if (!harnessId) { + return ( + navigate(`/agentcore/harness/exec/${id}`)} + /> + ); + } + return ( + + ); +} diff --git a/src/handlers/harness/get/screen.tsx b/src/handlers/harness/get/screen.tsx index 36b9cf52ae..e50378b0bd 100644 --- a/src/handlers/harness/get/screen.tsx +++ b/src/handlers/harness/get/screen.tsx @@ -46,7 +46,7 @@ const ACTIONS: { name: string; description: string; to: (id: string) => string } { name: "exec", description: "run shell commands in this harness", - to: (id) => `/agentcore/exec/harness/${id}`, + to: (id) => `/agentcore/harness/exec/${id}`, }, { name: "update", diff --git a/src/handlers/harness/index.tsx b/src/handlers/harness/index.tsx index c7f04739cb..9c29e98968 100644 --- a/src/handlers/harness/index.tsx +++ b/src/handlers/harness/index.tsx @@ -9,6 +9,7 @@ import { createCreateHarnessHandler } from "./create"; import { createUpdateHarnessHandler } from "./update"; import { createDeleteHarnessHandler } from "./delete"; import { createInvokeHarnessHandler } from "./invoke"; +import { createExecHarnessHandler } from "./exec"; import { createEndpointHandler } from "./endpoint"; import { createVersionHandler } from "./version"; import { createHarnessLogsHandler } from "./logs"; @@ -28,6 +29,7 @@ export function createHarnessHandler(core: Core, io: AppIO): Router { "update", "delete", "invoke", + "exec", "endpoint", "version", ); @@ -39,6 +41,7 @@ export function createHarnessHandler(core: Core, io: AppIO): Router { harness.handler(createUpdateHarnessHandler(core)); harness.handler(createDeleteHarnessHandler(core)); harness.handler(createInvokeHarnessHandler(core, io)); + harness.handler(createExecHarnessHandler(core, io)); harness.handler(createHarnessLogsHandler(core, io)); harness.handler(createHarnessTracesHandler(core, io)); 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/exec/runtime/screen.tsx b/src/handlers/runtime/exec/screen.tsx similarity index 96% rename from src/handlers/exec/runtime/screen.tsx rename to src/handlers/runtime/exec/screen.tsx index 47306ebf78..4f25b54dde 100644 --- a/src/handlers/exec/runtime/screen.tsx +++ b/src/handlers/runtime/exec/screen.tsx @@ -25,25 +25,25 @@ const theme = darkTheme; type RuntimeExecItem = ExecItem | Extract; const execPath = (runtimeId?: string, sessionId?: string) => { - const parts = ["/agentcore/exec/runtime"]; + 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 { resourceId, sessionId } = useParams(); + 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 (!resourceId) { + if (!runtimeId) { return ( navigate(execPath(id, sessionId))} /> @@ -53,7 +53,7 @@ export function RuntimeExecScreen(props: ScreenProps) { return ( + createHandler({ + name: "shell", + description: "open an interactive shell in a Runtime", + flags: [ + flag("id", "the ID of the Runtime", runtimeIdSchema), + flag("qualifier", "the Runtime endpoint qualifier", z.string().min(1).optional()), + flag("session-id", "the Runtime 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 bearerToken = await resolveRuntimeShellBearerToken(flags["bearer-token"], io.stdin); + const launchContext = { + runtimeId: flags.id, + runtimeSessionId: flags["session-id"], + bearerToken, + }; + if (flags.qualifier === undefined) { + await renderTuiAt( + `${ctx.require(PathKey)}/${encodeURIComponent(flags.id)}`, + ctx.withValue(RuntimeShellLaunchContextKey, launchContext), + core, + io, + ); + return; + } + await runRuntimeShell({ + ctx, + core, + io, + runtimeId: flags.id, + qualifier: flags.qualifier, + launchContext, + }); + }, + }); + +export { RuntimeShellScreen } from "./screen"; diff --git a/src/handlers/shell/index.tsx b/src/handlers/shell/index.tsx index 2af7ef3bf9..90e2815a90 100644 --- a/src/handlers/shell/index.tsx +++ b/src/handlers/shell/index.tsx @@ -4,9 +4,12 @@ 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({ @@ -46,13 +49,23 @@ export const createShellHandler = (core: Core, io: AppIO) => ? resourceCtx.withValue(RegionKey, resourceRegion) : resourceCtx; const runtimeId = serviceIdFromArn(resourceArn); - const bearerToken = flags["bearer-token"]; + 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, From 5eb192809870a860a1fa775e83f8e462409e96a0 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 25 Sep 2026 17:27:34 +0000 Subject: [PATCH 8/8] test: update resource command menus --- src/components/CliOnlyScreen.test.tsx | 12 +++++++++++- src/handlers/runtime/runtime.test.tsx | 2 ++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/CliOnlyScreen.test.tsx b/src/components/CliOnlyScreen.test.tsx index e9265f8b47..fee60a2b17 100644 --- a/src/components/CliOnlyScreen.test.tsx +++ b/src/components/CliOnlyScreen.test.tsx @@ -81,7 +81,17 @@ describe("menus list command-line-only subcommands below a divider", () => { await waitForText(r.lastFrame, "command line only"); expect(menuEntries(r.lastFrame()!)).toEqual({ - screens: ["create", "get", "list", "update", "delete", "invoke", "endpoint", "version"], + screens: [ + "create", + "get", + "list", + "update", + "delete", + "invoke", + "exec", + "endpoint", + "version", + ], cliOnly: ["logs", "traces"], }); r.unmount(); diff --git a/src/handlers/runtime/runtime.test.tsx b/src/handlers/runtime/runtime.test.tsx index 714fd99f76..6b400c0669 100644 --- a/src/handlers/runtime/runtime.test.tsx +++ b/src/handlers/runtime/runtime.test.tsx @@ -117,6 +117,8 @@ describe("runtime command hierarchy", () => { "get", "list", "invoke", + "exec", + "shell", "version", "endpoint", "logs",