Skip to content
Closed
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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ agentcore # interactive TUI
│ ├── get # fetch a Runtime by id
│ ├── list # list Runtimes (server-side paginated)
│ ├── invoke # invoke a Runtime headlessly or in a persistent console
│ ├── shell # open a persistent interactive terminal in a Runtime
│ ├── logs # follow a Runtime's logs live, or search a time window
│ ├── traces
│ │ ├── list # list a Runtime's recent traces
Expand Down Expand Up @@ -516,6 +517,38 @@ accept ARNs, `--version`, `--interactive`, cross-account targets, or custom
request paths. All requests use the Runtime `/invocations` route, including MCP
Runtimes.

### Open a Runtime shell

Runtime Shell opens a persistent interactive terminal in a Runtime session.
Bare shell opens the Runtime and endpoint pickers. `--id` skips the Runtime
picker, and `--id` plus `--qualifier` connects directly.

```bash
agentcore runtime shell
agentcore runtime shell --id <runtimeId>
agentcore runtime shell --id <runtimeId> --qualifier DEFAULT
```

Use `--session-id` to open the shell in a specific Runtime session/VM:

```bash
agentcore runtime shell \
--id <runtimeId> \
--qualifier DEFAULT \
--session-id <runtimeSessionId>
```

CUSTOM_JWT Runtimes require `--bearer-token`. Interactive bearer tokens may be
inline or `file://` sources, but not stdin.

The shell forwards terminal input byte-for-byte, including `Ctrl+C`, `Ctrl+D`,
escape sequences, and full-screen terminal applications. Terminal resize events
update the remote PTY. Running `exit` or sending `Ctrl+D` terminates the remote
shell.

Runtime Shell requires TTY stdin and stdout and does not support `--json` or
`--endpoint-url`.

Bare Runtime branches and leaves, plus `memory`, `memory get`, and `memory list`,
require a TTY on stdin and stdout.
For Runtime Invoke, supplying a payload or headless-only request or output flags
Expand Down
151 changes: 150 additions & 1 deletion bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"@opentelemetry/sdk-metrics": "^2.10.0",
"@smithy/core": "^3.33.3",
"@tanstack/react-query": "^5.101.2",
"bedrock-agentcore": "^0.4.3",
"cli-truncate": "^6.1.1",
"commander": "^15.0.0",
"handlebars": "^4.7.9",
Expand Down
17 changes: 16 additions & 1 deletion scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,20 @@ function assetLoaderPlugin(): Bun.BunPlugin {
};
}

function runtimeShellSdkPlugin(): Bun.BunPlugin {
const runtimeEntry = Bun.resolveSync("bedrock-agentcore/runtime", REPO_ROOT);
const runtimeClient = join(resolve(runtimeEntry, ".."), "client.js");

return {
name: "runtime-shell-sdk-client",
setup(build) {
build.onResolve({ filter: /^bedrock-agentcore\/runtime$/ }, () => ({
path: runtimeClient,
}));
},
};
}

function bootstrapTemplate(): string {
const manifest = Bun.resolveSync("@aws-cdk/toolkit-lib/package.json", REPO_ROOT);
const template = join(resolve(manifest, ".."), ...BOOTSTRAP_TEMPLATE);
Expand Down Expand Up @@ -133,6 +147,7 @@ async function bundle(): Promise<void> {
minify: MINIFY,
define: DEFINE,
external: EXTERNAL,
plugins: [runtimeShellSdkPlugin()],
});
const bin = join(DIST, "index.js");
await Bun.write(bin, BIN_LOADER);
Expand Down Expand Up @@ -165,7 +180,7 @@ async function compile(target: string): Promise<void> {
define: DEFINE,
root: REPO_ROOT,
naming: { asset: ASSET_NAMING },
plugins: [assetLoaderPlugin()],
plugins: [assetLoaderPlugin(), runtimeShellSdkPlugin()],
});
await assertTemplateIsEmbedded(outfile, template);
console.log(
Expand Down
13 changes: 13 additions & 0 deletions src/components/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { MemoryScreen } from "../handlers/memory/screen.tsx";
import { MemoryGetJsonScreen, MemoryGetScreen } from "../handlers/memory/get/screen.tsx";
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 { 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 @@ -378,6 +379,18 @@ export function Root({ path, ctx, core, queryClient }: RootProps) {
path="agentcore/runtime/invoke/:runtimeId/:qualifier"
element={<RuntimeInvokeScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/runtime/shell"
element={<RuntimeShellScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/runtime/shell/:runtimeId"
element={<RuntimeShellScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/runtime/shell/:runtimeId/:qualifier"
element={<RuntimeShellScreen ctx={ctx} core={core} />}
/>
<Route path="agentcore/gateway" element={<GatewayScreen ctx={ctx} core={core} />} />
<Route
path="agentcore/gateway/get"
Expand Down
9 changes: 8 additions & 1 deletion src/core/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { MemoryClient } from "./memory";
import { PolicyClient } from "./policy";
import { ObservabilityClient } from "./observability";
import { RuntimeClient } from "./runtime";
import type { OpenRuntimeShell } from "./runtime";
import { FsReadWriteJson } from "../io";
import type {
AwsClients,
Expand Down Expand Up @@ -49,6 +50,7 @@ type CoreClientConfig = {
newSessionId?: () => string;
now?: () => number;
bedrockAgentImporter?: CoreBedrockAgentImporter;
openRuntimeShell?: OpenRuntimeShell;
};

// CoreClient is the single entry point to the Bedrock AgentCore APIs. It owns the
Expand Down Expand Up @@ -89,7 +91,12 @@ export class CoreClient implements AwsClients {
this.logger = config.logger;
const fetch = config.fetch ?? globalThis.fetch;
this.fetch = fetch;
this.runtime = new RuntimeClient(this, fetch, this.logger.child({ module: "runtime" }));
this.runtime = new RuntimeClient(
this,
fetch,
this.logger.child({ module: "runtime" }),
config.openRuntimeShell,
);
this.gateway = new GatewayClient(this, fetch, this.logger.child({ module: "gateway" }));
this.policy = new PolicyClient(this, this.logger.child({ module: "policy" }));
// EvalClient shares the injected fetch: dataset content is served from a
Expand Down
17 changes: 17 additions & 0 deletions src/core/runtime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,27 @@ import type {
CoreRuntimeClient,
RuntimeInvokeRequest,
RuntimeInvokeResponse,
RuntimeShellRequest,
RuntimeShellSession,
} from "../handlers/runtime/types";
import type { Logger } from "../logging";
import type { AwsClients, CoreFetch, CoreOptions } from "./types";
import { invokeRuntime } from "./invokeRuntime";
import { toClientConfig } from "./utils";

export type OpenRuntimeShell = (
request: RuntimeShellRequest,
options: CoreOptions,
) => Promise<RuntimeShellSession>;

export class RuntimeClient implements CoreRuntimeClient {
constructor(
private readonly clients: AwsClients,
private readonly fetch: CoreFetch,
private readonly logger: Logger,
private readonly openShell: OpenRuntimeShell = async () => {
throw new Error("Runtime shell transport is not configured");
},
) {}

invokeRuntime(
Expand All @@ -40,6 +50,13 @@ export class RuntimeClient implements CoreRuntimeClient {
);
}

openRuntimeShell(
request: RuntimeShellRequest,
options: CoreOptions,
): Promise<RuntimeShellSession> {
return this.openShell(request, options);
}

async getRuntime(
id: string,
options: CoreOptions,
Expand Down
198 changes: 198 additions & 0 deletions src/core/runtimeShell.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { describe, expect, test } from "bun:test";
import { ShellChannel } from "bedrock-agentcore/runtime";
import type { RuntimeShellRequest } from "../handlers/runtime/types";
import {
createRuntimeShellOpener,
type RuntimeShellSdkClient,
type RuntimeShellSdkSession,
} from "./runtimeShell";

const REQUEST: RuntimeShellRequest = {
runtimeArn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/checkout-AbCdEf1234",
qualifier: "prod",
runtimeSessionId: "session-012345678901234567890123456789",
};

function sdkSession(
frames: { channel: ShellChannel; payload: Uint8Array }[] = [],
): RuntimeShellSdkSession & { closed: number } {
return {
sessionId: "server-session",
kicked: false,
exitCode: 0,
closed: 0,
send: async () => {},
resize: async () => {},
async close() {
this.closed += 1;
},
async *[Symbol.asyncIterator]() {
yield* frames;
},
};
}

describe("createRuntimeShellOpener", () => {
test("constructs a SigV4 client from Core options and opens the requested shell", async () => {
const clients: unknown[] = [];
const opens: unknown[] = [];
const session = sdkSession();
const opener = createRuntimeShellOpener({
createClient: (config) => {
clients.push(config);
return {
openShell: async (input) => {
opens.push(input);
return session;
},
};
},
sleep: async () => {},
});
const credentials = {
accessKeyId: "access",
secretAccessKey: "secret",
sessionToken: "session",
};

const result = await opener(REQUEST, {
region: "us-west-2",
endpointUrl: "https://runtime.test",
credentials,
});

expect(clients).toEqual([
{
region: "us-west-2",
credentialsProvider: expect.any(Function),
},
]);
await expect(
(clients[0] as { credentialsProvider: () => Promise<unknown> }).credentialsProvider(),
).resolves.toEqual(credentials);
expect(opens).toEqual([
{
runtimeArn: REQUEST.runtimeArn,
endpointName: "prod",
sessionId: REQUEST.runtimeSessionId,
auth: "sigv4",
reconnectConfig: {},
},
]);
expect(result.runtimeSessionId).toBe("server-session");
});

test("uses OAuth auth for a bearer token", async () => {
const opens: unknown[] = [];
const opener = createRuntimeShellOpener({
createClient: () => ({
openShell: async (input) => {
opens.push(input);
return sdkSession();
},
}),
sleep: async () => {},
});

await opener({ ...REQUEST, bearerToken: "token" }, { region: "us-west-2" });

expect(opens).toEqual([
expect.objectContaining({ auth: { type: "oauth", bearerToken: "token" } }),
]);
});

test("translates stdout and stderr frames and delegates writes", async () => {
const sent: (string | Buffer)[] = [];
const resizes: unknown[] = [];
const session = sdkSession([
{ channel: ShellChannel.STDOUT, payload: new TextEncoder().encode("out") },
{ channel: ShellChannel.STATUS, payload: new Uint8Array() },
{ channel: ShellChannel.STDERR, payload: new TextEncoder().encode("err") },
]);
session.send = async (data) => {
sent.push(data);
};
session.resize = async (columns, rows) => {
resizes.push({ columns, rows });
};
const opener = createRuntimeShellOpener({
createClient: (): RuntimeShellSdkClient => ({ openShell: async () => session }),
sleep: async () => {},
});

const result = await opener(REQUEST, { region: "us-west-2" });
const frames = [];
for await (const frame of result) frames.push(frame);
await result.send(Uint8Array.from([1, 2]));
await result.resize(100, 40);
await result.close();

expect(frames).toEqual([
{ type: "stdout", data: new TextEncoder().encode("out") },
{ type: "stderr", data: new TextEncoder().encode("err") },
]);
expect(sent).toEqual([Buffer.from([1, 2])]);
expect(Buffer.isBuffer(sent[0])).toBe(true);
expect(resizes).toEqual([{ columns: 100, rows: 40 }]);
expect(session.closed).toBe(1);
});

test("splits large terminal writes at the shell protocol frame limit", async () => {
const sent: Buffer[] = [];
const session = sdkSession();
session.send = async (data) => {
if (typeof data === "string") throw new Error("expected Buffer");
sent.push(Buffer.from(data));
};
const opener = createRuntimeShellOpener({
createClient: () => ({ openShell: async () => session }),
sleep: async () => {},
});
const result = await opener(REQUEST, { region: "us-west-2" });
const paste = Buffer.alloc(64 * 1024 + 1, 0x61);

await result.send(paste);

expect(sent.map((frame) => frame.byteLength)).toEqual([64 * 1024 - 1, 2]);
expect(Buffer.concat(sent)).toEqual(paste);
});

test("retries retryable initial upgrade failures", async () => {
let attempts = 0;
const delays: number[] = [];
const opener = createRuntimeShellOpener({
createClient: () => ({
openShell: async () => {
attempts += 1;
if (attempts < 3) throw new Error("Server rejected WebSocket connection: HTTP 424");
return sdkSession();
},
}),
sleep: async (delay) => {
delays.push(delay);
},
});

await opener(REQUEST, { region: "us-west-2" });

expect(attempts).toBe(3);
expect(delays).toEqual([250, 500]);
});

test("does not retry a non-retryable failure", async () => {
let attempts = 0;
const failure = new Error("Server rejected WebSocket connection: HTTP 403");
const opener = createRuntimeShellOpener({
createClient: () => ({
openShell: async () => {
attempts += 1;
throw failure;
},
}),
sleep: async () => {},
});

await expect(opener(REQUEST, { region: "us-west-2" })).rejects.toBe(failure);
expect(attempts).toBe(1);
});
});
Loading
Loading