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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -530,22 +530,24 @@ 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:
Use both IDs to reattach to the same shell:

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

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.
update the remote PTY. `Ctrl+]` detaches the client while leaving the shell
available for reattachment. 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`.
Expand Down
6 changes: 5 additions & 1 deletion src/core/runtimeShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ const REQUEST: RuntimeShellRequest = {
runtimeArn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/checkout-AbCdEf1234",
qualifier: "prod",
runtimeSessionId: "session-012345678901234567890123456789",
shellId: "shell-1",
};

function sdkSession(
frames: { channel: ShellChannel; payload: Buffer }[] = [],
): RuntimeShellSdkSession & { closed: number } {
return {
shellId: "server-shell",
sessionId: "server-session",
reconnected: false,
kicked: false,
Expand Down Expand Up @@ -77,11 +79,13 @@ describe("createRuntimeShellOpener", () => {
runtimeArn: REQUEST.runtimeArn,
endpointName: "prod",
sessionId: REQUEST.runtimeSessionId,
shellId: REQUEST.shellId,
auth: "sigv4",
reconnectConfig: {},
},
]);
expect(result.runtimeSessionId).toBe("server-session");
expect(result.shellId).toBe("server-shell");
});

test("uses OAuth auth for a bearer token", async () => {
Expand Down Expand Up @@ -150,7 +154,7 @@ describe("createRuntimeShellOpener", () => {
for await (const frame of result) frames.push(frame);
await result.send(Uint8Array.from([1, 2]));
await result.resize(100, 40);
await result.close();
await result.detach();

expect(frames).toEqual([
{ type: "stdout", data: new TextEncoder().encode("out") },
Expand Down
9 changes: 7 additions & 2 deletions src/core/runtimeShell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export type RuntimeShellSdkFrame = Pick<ShellFrame, "channel" | "payload">;

export type RuntimeShellSdkSession = Pick<
ShellSession,
"sessionId" | "reconnected" | "kicked" | "exitCode" | "send" | "resize" | "close"
"shellId" | "sessionId" | "reconnected" | "kicked" | "exitCode" | "send" | "resize" | "close"
> &
AsyncIterable<RuntimeShellSdkFrame>;

Expand Down Expand Up @@ -59,6 +59,7 @@ export function createRuntimeShellOpener(config: RuntimeShellOpenerConfig = {}):
runtimeArn: request.runtimeArn,
endpointName: request.qualifier,
...(request.runtimeSessionId !== undefined && { sessionId: request.runtimeSessionId }),
...(request.shellId !== undefined && { shellId: request.shellId }),
auth:
request.bearerToken === undefined
? "sigv4"
Expand Down Expand Up @@ -105,6 +106,10 @@ class RuntimeShellSessionAdapter implements RuntimeShellSession {
return this.session.sessionId;
}

get shellId(): string {
return this.session.shellId;
}

get kicked(): boolean {
return this.session.kicked;
}
Expand All @@ -123,7 +128,7 @@ class RuntimeShellSessionAdapter implements RuntimeShellSession {
return this.session.resize(columns, rows);
}

close(): Promise<void> {
detach(): Promise<void> {
return this.session.close();
}

Expand Down
18 changes: 16 additions & 2 deletions src/handlers/runtime/shell/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@ import type { Core } from "../../types";
import { runtimeIdSchema } from "../invoke/request";
import { RuntimeShellLaunchContextKey } from "./launchContext";
import { runRuntimeShell } from "./operation";
import { resolveRuntimeShellBearerToken } from "./request";
import { resolveRuntimeShellBearerToken, validateRuntimeShellIds } from "./request";

const shellIdSchema = z
.string()
.regex(
/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/,
"must start with an alphanumeric character and contain at most 128 alphanumeric, '-' or '_' characters",
);

export const createRuntimeShellHandler = (core: Core, io: AppIO) =>
createHandler({
Expand All @@ -17,7 +24,12 @@ export const createRuntimeShellHandler = (core: Core, io: AppIO) =>
flags: [
flag("id", "the ID of the Runtime", runtimeIdSchema.optional()),
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(
"session-id",
"the Runtime session ID to resume",
z.string().min(33).max(256).optional(),
),
flag("shell-id", "the shell ID to reattach", shellIdSchema.optional()),
flag("bearer-token", "the CUSTOM_JWT bearer token", z.string().optional(), {
sensitive: true,
}),
Expand All @@ -29,10 +41,12 @@ export const createRuntimeShellHandler = (core: Core, io: AppIO) =>
if (flags.id === undefined) {
throw new InputValidationError("required option '--id <id>' not specified");
}
validateRuntimeShellIds(flags["session-id"], flags["shell-id"]);
const bearerToken = await resolveRuntimeShellBearerToken(flags["bearer-token"], io.stdin);
const launchContext = {
runtimeId: flags.id,
runtimeSessionId: flags["session-id"],
shellId: flags["shell-id"],
bearerToken,
};
if (flags.qualifier === undefined) {
Expand Down
1 change: 1 addition & 0 deletions src/handlers/runtime/shell/launchContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { contextKey } from "../../../router";
export type RuntimeShellLaunchContext = {
runtimeId: string;
runtimeSessionId?: string;
shellId?: string;
bearerToken?: string;
};

Expand Down
20 changes: 17 additions & 3 deletions src/handlers/runtime/shell/operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export async function runRuntimeShell(input: RunRuntimeShellInput): Promise<void
const request = normalizeRuntimeShellRequest(detail, {
qualifier,
runtimeSessionId: launchContext?.runtimeSessionId,
shellId: launchContext?.shellId,
bearerToken: launchContext?.bearerToken,
});
request.onReconnect = (reconnected) => {
Expand All @@ -44,13 +45,26 @@ export async function runRuntimeShell(input: RunRuntimeShellInput): Promise<void

io.stderr.write(`Connecting to Runtime ${runtimeId} (${qualifier})...\n`);
const session = await core.runtime.openRuntimeShell(request, options);
io.stderr.write(`Connected · session ${session.runtimeSessionId} · Ctrl+D or 'exit' to quit\n`);
io.stderr.write(
`Connected · session ${session.runtimeSessionId} · shell ${session.shellId} · ` +
`Ctrl+D or 'exit' to quit · Ctrl+] to detach\n`,
);

const terminal = new InteractiveTerminal({ io });
let result: { detached: boolean };
try {
await terminal.run(session);
result = await terminal.run(session);
} finally {
await session.close();
await session.detach();
}

if (result.detached) {
io.stderr.write(
`\nDetached.\nTo reattach:\n` +
` agentcore runtime shell --id ${runtimeId} --qualifier ${qualifier} \\\n` +
` --session-id ${session.runtimeSessionId} --shell-id ${session.shellId}\n`,
);
return;
}
if (session.kicked) {
io.stderr.write("\nShell attached from another client.\n");
Expand Down
23 changes: 22 additions & 1 deletion src/handlers/runtime/shell/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { describe, expect, test } from "bun:test";
import type { GetAgentRuntimeResponse } from "@aws-sdk/client-bedrock-agentcore-control";
import { PassThrough } from "node:stream";
import { rm } from "node:fs/promises";
import { normalizeRuntimeShellRequest, resolveRuntimeShellBearerToken } from "./request";
import {
normalizeRuntimeShellRequest,
resolveRuntimeShellBearerToken,
validateRuntimeShellIds,
} from "./request";

const RUNTIME_ARN = "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/checkout-AbCdEf1234";

Expand Down Expand Up @@ -31,11 +35,13 @@ describe("normalizeRuntimeShellRequest", () => {
normalizeRuntimeShellRequest(runtime(), {
qualifier: "prod",
runtimeSessionId: "session-012345678901234567890123456789",
shellId: "shell-1",
}),
).toEqual({
runtimeArn: RUNTIME_ARN,
qualifier: "prod",
runtimeSessionId: "session-012345678901234567890123456789",
shellId: "shell-1",
});
});

Expand Down Expand Up @@ -85,6 +91,21 @@ describe("normalizeRuntimeShellRequest", () => {
});
});

describe("validateRuntimeShellIds", () => {
test("requires session ID when shell ID is supplied", () => {
expect(() => validateRuntimeShellIds(undefined, "shell-1")).toThrow(
"--shell-id requires --session-id",
);
});

test("accepts both IDs or neither", () => {
expect(() => validateRuntimeShellIds(undefined, undefined)).not.toThrow();
expect(() =>
validateRuntimeShellIds("session-012345678901234567890123456789", "shell-1"),
).not.toThrow();
});
});

describe("resolveRuntimeShellBearerToken", () => {
test("reads file:// tokens and strips one trailing newline", async () => {
const path = `${import.meta.dir}/token.test.txt`;
Expand Down
12 changes: 12 additions & 0 deletions src/handlers/runtime/shell/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ import type { RuntimeShellRequest } from "../types";

export type RuntimeShellInput = Omit<RuntimeShellRequest, "runtimeArn">;

export function validateRuntimeShellIds(
runtimeSessionId: string | undefined,
shellId: string | undefined,
): void {
if (shellId !== undefined && runtimeSessionId === undefined) {
throw new InputValidationError("--shell-id requires --session-id");
}
}

export async function resolveRuntimeShellBearerToken(
source: string | undefined,
stdin: NodeJS.ReadStream,
Expand Down Expand Up @@ -53,12 +62,15 @@ export function normalizeRuntimeShellRequest(
if (!customJwt && input.bearerToken !== undefined) {
throw new InputValidationError("IAM Runtime does not accept --bearer-token");
}
validateRuntimeShellIds(input.runtimeSessionId, input.shellId);

return {
runtimeArn,
qualifier: input.qualifier,
...(input.runtimeSessionId !== undefined && {
runtimeSessionId: input.runtimeSessionId,
}),
...(input.shellId !== undefined && { shellId: input.shellId }),
...(input.bearerToken !== undefined && { bearerToken: input.bearerToken }),
};
}
3 changes: 2 additions & 1 deletion src/handlers/runtime/shell/shell.screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,12 @@ describe("RuntimeShellScreen", () => {
const value = core();
const failedSession: RuntimeShellSession = {
runtimeSessionId: "session-012345678901234567890123456789",
shellId: "shell-1",
kicked: false,
exitCode: 42,
send: async () => {},
resize: async () => {},
close: async () => {},
detach: async () => {},
async *[Symbol.asyncIterator]() {},
};
value.runtime.setShellSession(failedSession);
Expand Down
19 changes: 14 additions & 5 deletions src/handlers/runtime/shell/shell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ function runtime(overrides: Partial<GetAgentRuntimeResponse> = {}): GetAgentRunt

class CompletedShell implements RuntimeShellSession {
readonly runtimeSessionId = "session-012345678901234567890123456789";
readonly shellId = "shell-1";
readonly kicked = false;
readonly exitCode = 0;
closed = 0;
detached = 0;

send(): Promise<void> {
return Promise.resolve();
Expand All @@ -46,8 +47,8 @@ class CompletedShell implements RuntimeShellSession {
return Promise.resolve();
}

close(): Promise<void> {
this.closed += 1;
detach(): Promise<void> {
this.detached += 1;
return Promise.resolve();
}

Expand Down Expand Up @@ -75,7 +76,7 @@ function harness(options: { isTTY?: boolean; runtime?: GetAgentRuntimeResponse }
}

describe("runtime shell command", () => {
test("opens a direct IAM shell and closes after the remote stream ends", async () => {
test("opens a direct IAM shell and detaches after the remote stream ends", async () => {
const subject = harness();

await subject.run("--id", RUNTIME_ID, "--qualifier", "prod");
Expand All @@ -94,7 +95,7 @@ describe("runtime shell command", () => {
{ region: "us-west-2", endpointUrl: undefined },
],
});
expect(subject.shell.closed).toBe(1);
expect(subject.shell.detached).toBe(1);
expect(subject.io.stderr()).toContain("Connected");
expect(subject.io.stderr()).toContain("exit 0");
});
Expand Down Expand Up @@ -164,4 +165,12 @@ describe("runtime shell command", () => {
).rejects.toThrow("runtime shell does not support --endpoint-url");
expect(subject.core.runtime.calls.some((call) => call.method === "getRuntime")).toBe(false);
});

test("requires session ID when shell ID is supplied", async () => {
const subject = harness();

await expect(
subject.run("--id", RUNTIME_ID, "--qualifier", "DEFAULT", "--shell-id", "shell-1"),
).rejects.toThrow("--shell-id requires --session-id");
});
});
4 changes: 3 additions & 1 deletion src/handlers/runtime/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export type RuntimeShellRequest = {
runtimeArn: string;
qualifier: string;
runtimeSessionId?: string;
shellId?: string;
bearerToken?: string;
onReconnect?: (reconnected: boolean) => void | Promise<void>;
};
Expand All @@ -67,11 +68,12 @@ export type RuntimeShellFrame =

export interface RuntimeShellSession extends AsyncIterable<RuntimeShellFrame> {
readonly runtimeSessionId: string;
readonly shellId: string;
readonly kicked: boolean;
readonly exitCode: number | null;
send(data: Uint8Array): Promise<void>;
resize(columns: number, rows: number): Promise<void>;
close(): Promise<void>;
detach(): Promise<void>;
}

export interface CoreRuntimeClient {
Expand Down
1 change: 1 addition & 0 deletions src/io/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export {
InteractiveTerminal,
type InteractiveTerminalConfig,
type InteractiveTerminalPeer,
type InteractiveTerminalResult,
type TerminalFrame,
} from "./interactiveTerminal";
export { readTextFile, type ReadTextFileOptions } from "./fileRead";
Expand Down
Loading
Loading