Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> --command "uname -a"
```

## What problem does it solve?
Expand Down Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/harness-project-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
13 changes: 12 additions & 1 deletion src/components/CliOnlyScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
10 changes: 10 additions & 0 deletions src/components/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -480,6 +481,15 @@ function RouteTable({ ctx, core }: ScreenProps) {
path="agentcore/runtime/shell/:runtimeId/:qualifier"
element={<RuntimeShellScreen ctx={ctx} core={core} />}
/>
<Route path="agentcore/runtime/exec" element={<RuntimeExecScreen ctx={ctx} core={core} />} />
<Route
path="agentcore/runtime/exec/:runtimeId"
element={<RuntimeExecScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/runtime/exec/:runtimeId/:sessionId"
element={<RuntimeExecScreen ctx={ctx} core={core} />}
/>
<Route path="agentcore/gateway" element={<GatewayScreen ctx={ctx} core={core} />} />
<Route
path="agentcore/gateway/create"
Expand Down
2 changes: 1 addition & 1 deletion src/components/RouterScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
StreamController,
TestCoreClient,
waitFor,
} from "../../../testing";
} from "../../testing";

afterEach(cleanupScreens);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@ import type {
InvokeAgentRuntimeCommandStreamOutput,
} from "@aws-sdk/client-bedrock-agentcore";
import type { GetHarnessResponse } from "@aws-sdk/client-bedrock-agentcore-control";
import { createRootHandler } from "../../index";
import { createRootHandler } from "../index";
import {
IMPERATIVE_GLOBAL_CONFIG,
createSilentLogger,
expectError,
TestCoreClient,
testIO,
} from "../../../testing";
import { TestGlobalConfigAccessor } from "../../../testing/";
import { InputValidationError } from "../../../errors";
} from "../../testing";
import { TestGlobalConfigAccessor } from "../../testing/";
import { InputValidationError } from "../../errors";

// Command-flow tests for `harness exec`, driven through the real root handler.
// Command-flow tests for top-level `exec --harness`, driven through the real root handler.
// Like the invoke suite, these use a TestCoreClient because the command
// response is an AsyncIterable stream that fixtures cannot capture.

Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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" } } },
Expand All @@ -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/,
);
});
Expand Down
114 changes: 114 additions & 0 deletions src/handlers/exec/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import z from "zod";
import { ResourceNotFoundError, InputValidationError } from "../../errors";
import { createHandler, flag } from "../../router";
import { JsonKey } from "../keys";
import type { AppIO } from "../../io";
import type { Core } from "../types";
import {
assertMutuallyExclusiveFlags,
contextForResource,
coreOptsFromCtx,
toResourceArn,
} from "../utils";
import { JsonRendererKey, renderTuiAt } from "../../tui";
import { regionFromArn, serviceIdFromArn } from "../../core/arn";
import { RegionKey } from "../keys";
import { invokeExecCommand } from "./operation";

export const createExecHandler = (core: Core, io: AppIO) =>
createHandler({
name: "exec",
description: "run a shell command in a Runtime or harness",
flags: [
flag(
"runtime",
"the name a Runtime in the project, or ID of a Runtime in the account",
z.string().min(1).optional(),
),
flag(
"harness",
"the name of a harness in the project, or ID of a harness in the account",
z.string().min(1).optional(),
),
flag("command", "the shell command to run", z.string().optional()),
flag(
"session-id",
"the session ID to run in (33-100 characters)",
z.string().min(33).max(100).optional(),
),
flag(
"qualifier",
"the endpoint qualifier to run in (default DEFAULT)",
z.string().optional(),
),
flag(
"timeout",
"seconds to wait for the command (1-3600)",
z.number().int().min(1).max(3600).optional(),
),
],
handle: async (ctx, flags) => {
assertMutuallyExclusiveFlags(flags, ["runtime", "harness"]);

const resourceType =
flags.runtime !== undefined
? "runtime"
: flags.harness !== undefined
? "harness"
: undefined;
const identifier = flags.runtime ?? flags.harness;
if (resourceType === undefined || identifier === undefined) {
throw new InputValidationError("specify one of --runtime or --harness");
}

const resourceCtx = await contextForResource({
core,
context: ctx,
resourceType,
identifier,
});
const resourceArn = await toResourceArn({
core,
context: resourceCtx,
resourceType,
identifier,
});
if (resourceArn === undefined) {
throw new ResourceNotFoundError(
`${resourceType === "runtime" ? "Runtime" : "Harness"} '${identifier}' was not found`,
);
}

const resourceRegion = regionFromArn(resourceArn);
const resolvedCtx = resourceRegion
? resourceCtx.withValue(RegionKey, resourceRegion)
: resourceCtx;
const resourceId = serviceIdFromArn(resourceArn);
if (flags.command === undefined) {
if (ctx.require(JsonKey)) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i feel like we could simplify this here with an or to avoid the same error twice.

throw new InputValidationError("required option '--command <command>' not specified");
}
let path = `/agentcore/${resourceType === "runtime" ? "runtime/exec" : "harness/exec"}/${encodeURIComponent(resourceId)}`;
if (flags["session-id"]) path += `/${encodeURIComponent(flags["session-id"])}`;
const params = new URLSearchParams();
if (flags.qualifier) params.set("qualifier", flags.qualifier);
if (flags.timeout !== undefined) params.set("timeout", String(flags.timeout));
if (params.size > 0) path += `?${params}`;
await renderTuiAt(path, resolvedCtx, core, io);
return;
}

const result = await invokeExecCommand({

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i want to be able to exec in runtime. see previous comment.

core,
input: {
resourceArn,
command: flags.command,
runtimeSessionId: flags["session-id"],
qualifier: flags.qualifier ?? "DEFAULT",
timeout: flags.timeout,
},
options: coreOptsFromCtx(resolvedCtx),
});
ctx.require(JsonRendererKey).renderJson(result);
},
});
50 changes: 50 additions & 0 deletions src/handlers/exec/operation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { InvokeAgentRuntimeCommandRequest } from "@aws-sdk/client-bedrock-agentcore";
import type { CoreOptions } from "../../core/types";
import type { Core } from "../types";
import { applyExecEvent, finishExec, newExecItem } from "../harness/invoke/transcript";

export type ExecInput = {
resourceArn: string;
command: string;
runtimeSessionId?: string;
qualifier: string;
timeout?: number;
};

export type ExecResult = {
sessionId?: string;
command: string;
exitCode?: number;
status: "running" | "success" | "error";
output: string;
};

export async function invokeExecCommand({
core,
input,
options,
signal,
}: {
core: Core;
input: ExecInput;
options: CoreOptions;
signal?: AbortSignal;
}): Promise<ExecResult> {
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,
};
}
11 changes: 1 addition & 10 deletions src/handlers/harness/exec/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,6 @@ export const createExecHarnessHandler = (core: Core, io: AppIO) =>
),
],
handle: async (ctx, flags) => {
// Without a command, open the interactive exec screen at this harness —
// resuming the given session and targeting the given qualifier when
// passed. The one-shot CLI run below needs --command (and is the only
// shape JSON mode supports).
if (!flags["command"]) {
if (ctx.require(JsonKey)) {
throw new InputValidationError("required option '--command <command>' not specified");
Expand All @@ -49,11 +45,8 @@ export const createExecHarnessHandler = (core: Core, io: AppIO) =>

const opts = coreOptsFromCtx(ctx);
const detail = await core.harness.getHarness(flags["id"], opts);

const response = await core.harness.invokeAgentRuntimeCommand(
{
// A harness-managed runtime cannot be addressed by its own runtime
// ARN; the service expects the harness ARN here.
agentRuntimeArn: detail.harness?.arn,
qualifier: flags["qualifier"] ?? "DEFAULT",
runtimeSessionId: flags["session-id"],
Expand All @@ -63,9 +56,7 @@ export const createExecHarnessHandler = (core: Core, io: AppIO) =>
);

const item = newExecItem(flags["command"]);
for await (const event of response.stream ?? []) {
applyExecEvent(item, event);
}
for await (const event of response.stream ?? []) applyExecEvent(item, event);
finishExec(item);

ctx.require(JsonRendererKey).renderJson({
Expand Down
5 changes: 0 additions & 5 deletions src/handlers/harness/exec/screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,6 @@ import type { ScreenProps } from "../../types";
import { HarnessPicker } from "../../../components/HarnessPicker";
import { HarnessChat } from "../invoke/screen";

// HarnessExecScreen is `harness exec` in the TUI: the same chat screen as
// invoke, but starting in exec mode ($ prompt, enter runs a shell command in
// the session's container). Ctrl+E flips between exec and chat at any time.
// Without a `:harnessId` route value it renders the harness picker. A
// `:sessionId` route value resumes that runtime session.
export function HarnessExecScreen(props: ScreenProps) {
const { harnessId, sessionId } = useParams();
const [search] = useSearchParams();
Expand Down
Loading
Loading