diff --git a/README.md b/README.md index ca175928f..29ce57045 100644 --- a/README.md +++ b/README.md @@ -530,13 +530,14 @@ agentcore runtime shell --id agentcore runtime shell --id --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 \ --qualifier DEFAULT \ - --session-id + --session-id \ + --shell-id ``` CUSTOM_JWT Runtimes require `--bearer-token`. Interactive bearer tokens may be @@ -544,8 +545,9 @@ 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`. diff --git a/src/core/runtimeShell.test.ts b/src/core/runtimeShell.test.ts index 42274f356..05543df23 100644 --- a/src/core/runtimeShell.test.ts +++ b/src/core/runtimeShell.test.ts @@ -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, @@ -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 () => { @@ -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") }, diff --git a/src/core/runtimeShell.ts b/src/core/runtimeShell.ts index 6271753ea..ef1fec84b 100644 --- a/src/core/runtimeShell.ts +++ b/src/core/runtimeShell.ts @@ -17,7 +17,7 @@ export type RuntimeShellSdkFrame = Pick; export type RuntimeShellSdkSession = Pick< ShellSession, - "sessionId" | "reconnected" | "kicked" | "exitCode" | "send" | "resize" | "close" + "shellId" | "sessionId" | "reconnected" | "kicked" | "exitCode" | "send" | "resize" | "close" > & AsyncIterable; @@ -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" @@ -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; } @@ -123,7 +128,7 @@ class RuntimeShellSessionAdapter implements RuntimeShellSession { return this.session.resize(columns, rows); } - close(): Promise { + detach(): Promise { return this.session.close(); } diff --git a/src/handlers/runtime/shell/index.tsx b/src/handlers/runtime/shell/index.tsx index 04cd6ef0c..31932a30b 100644 --- a/src/handlers/runtime/shell/index.tsx +++ b/src/handlers/runtime/shell/index.tsx @@ -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({ @@ -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, }), @@ -29,10 +41,12 @@ export const createRuntimeShellHandler = (core: Core, io: AppIO) => if (flags.id === undefined) { throw new InputValidationError("required option '--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) { diff --git a/src/handlers/runtime/shell/launchContext.ts b/src/handlers/runtime/shell/launchContext.ts index f084c51f6..78c8228e3 100644 --- a/src/handlers/runtime/shell/launchContext.ts +++ b/src/handlers/runtime/shell/launchContext.ts @@ -3,6 +3,7 @@ import { contextKey } from "../../../router"; export type RuntimeShellLaunchContext = { runtimeId: string; runtimeSessionId?: string; + shellId?: string; bearerToken?: string; }; diff --git a/src/handlers/runtime/shell/operation.ts b/src/handlers/runtime/shell/operation.ts index 30615ed74..f314c6cf4 100644 --- a/src/handlers/runtime/shell/operation.ts +++ b/src/handlers/runtime/shell/operation.ts @@ -32,6 +32,7 @@ export async function runRuntimeShell(input: RunRuntimeShellInput): Promise { @@ -44,13 +45,26 @@ export async function runRuntimeShell(input: RunRuntimeShellInput): Promise { normalizeRuntimeShellRequest(runtime(), { qualifier: "prod", runtimeSessionId: "session-012345678901234567890123456789", + shellId: "shell-1", }), ).toEqual({ runtimeArn: RUNTIME_ARN, qualifier: "prod", runtimeSessionId: "session-012345678901234567890123456789", + shellId: "shell-1", }); }); @@ -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`; diff --git a/src/handlers/runtime/shell/request.ts b/src/handlers/runtime/shell/request.ts index 9621249f2..4d2ac4879 100644 --- a/src/handlers/runtime/shell/request.ts +++ b/src/handlers/runtime/shell/request.ts @@ -5,6 +5,15 @@ import type { RuntimeShellRequest } from "../types"; export type RuntimeShellInput = Omit; +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, @@ -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 }), }; } diff --git a/src/handlers/runtime/shell/shell.screen.test.tsx b/src/handlers/runtime/shell/shell.screen.test.tsx index 4989db37a..734976434 100644 --- a/src/handlers/runtime/shell/shell.screen.test.tsx +++ b/src/handlers/runtime/shell/shell.screen.test.tsx @@ -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); diff --git a/src/handlers/runtime/shell/shell.test.tsx b/src/handlers/runtime/shell/shell.test.tsx index 6b51f7db5..e7bf21fce 100644 --- a/src/handlers/runtime/shell/shell.test.tsx +++ b/src/handlers/runtime/shell/shell.test.tsx @@ -34,9 +34,10 @@ function runtime(overrides: Partial = {}): 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 { return Promise.resolve(); @@ -46,8 +47,8 @@ class CompletedShell implements RuntimeShellSession { return Promise.resolve(); } - close(): Promise { - this.closed += 1; + detach(): Promise { + this.detached += 1; return Promise.resolve(); } @@ -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"); @@ -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"); }); @@ -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"); + }); }); diff --git a/src/handlers/runtime/types.tsx b/src/handlers/runtime/types.tsx index 273afe7a6..9732ead2d 100644 --- a/src/handlers/runtime/types.tsx +++ b/src/handlers/runtime/types.tsx @@ -58,6 +58,7 @@ export type RuntimeShellRequest = { runtimeArn: string; qualifier: string; runtimeSessionId?: string; + shellId?: string; bearerToken?: string; onReconnect?: (reconnected: boolean) => void | Promise; }; @@ -67,11 +68,12 @@ export type RuntimeShellFrame = export interface RuntimeShellSession extends AsyncIterable { readonly runtimeSessionId: string; + readonly shellId: string; readonly kicked: boolean; readonly exitCode: number | null; send(data: Uint8Array): Promise; resize(columns: number, rows: number): Promise; - close(): Promise; + detach(): Promise; } export interface CoreRuntimeClient { diff --git a/src/io/index.ts b/src/io/index.ts index 8341746d3..b63139a83 100644 --- a/src/io/index.ts +++ b/src/io/index.ts @@ -23,6 +23,7 @@ export { InteractiveTerminal, type InteractiveTerminalConfig, type InteractiveTerminalPeer, + type InteractiveTerminalResult, type TerminalFrame, } from "./interactiveTerminal"; export { readTextFile, type ReadTextFileOptions } from "./fileRead"; diff --git a/src/io/interactiveTerminal.test.ts b/src/io/interactiveTerminal.test.ts index 9baf87aaf..3fdc42f24 100644 --- a/src/io/interactiveTerminal.test.ts +++ b/src/io/interactiveTerminal.test.ts @@ -62,7 +62,7 @@ class FakePeer implements InteractiveTerminalPeer { readonly frames = new StreamController(); readonly sent: Uint8Array[] = []; readonly resizes: { columns: number; rows: number }[] = []; - closed = 0; + detached = 0; send(data: Uint8Array): Promise { this.sent.push(Uint8Array.from(data)); @@ -74,8 +74,8 @@ class FakePeer implements InteractiveTerminalPeer { return Promise.resolve(); } - close(): Promise { - this.closed += 1; + detach(): Promise { + this.detached += 1; this.frames.end(); return Promise.resolve(); } @@ -130,7 +130,7 @@ describe("InteractiveTerminal", () => { peer.frames.emit({ type: "stderr", data: new TextEncoder().encode("err") }); peer.frames.end(); - await running; + await expect(running).resolves.toEqual({ detached: false }); expect(peer.sent).toEqual([Uint8Array.from([0x1b, 0x5b, 0x41])]); expect(s.stdout()).toBe("out"); expect(s.stderr()).toBe("err"); @@ -175,7 +175,7 @@ describe("InteractiveTerminal", () => { await running; }); - test("forwards Ctrl+C and Ctrl+] as raw input", async () => { + test("consumes Ctrl+] as detach and forwards Ctrl+C", async () => { const s = subject(); const peer = new FakePeer(); const running = s.terminal.run(peer); @@ -183,16 +183,14 @@ describe("InteractiveTerminal", () => { s.stdin.write(Uint8Array.from([0x03])); s.stdin.write(Uint8Array.from([0x1d])); - await Bun.sleep(0); - peer.frames.end(); - await running; - expect(peer.sent).toEqual([Uint8Array.from([0x03]), Uint8Array.from([0x1d])]); - expect(peer.closed).toBe(0); + await expect(running).resolves.toEqual({ detached: true }); + expect(peer.sent).toEqual([Uint8Array.from([0x03])]); + expect(peer.detached).toBe(1); expect(s.rawModes).toEqual([true, false]); }); - test("closes the peer and restores terminal state when aborted", async () => { + test("detaches and restores terminal state when aborted", async () => { const s = subject(); const peer = new FakePeer(); const controller = new AbortController(); @@ -203,7 +201,7 @@ describe("InteractiveTerminal", () => { controller.abort(interrupted); await expect(running).rejects.toBe(interrupted); - expect(peer.closed).toBe(1); + expect(peer.detached).toBe(1); expect(s.rawModes).toEqual([true, false]); expect(s.resizeRemoved()).toBe(1); }); @@ -214,7 +212,7 @@ describe("InteractiveTerminal", () => { const peer: InteractiveTerminalPeer = { send: async () => {}, resize: async () => {}, - close: async () => {}, + detach: async () => {}, [Symbol.asyncIterator]() { return { next: async (): Promise> => { diff --git a/src/io/interactiveTerminal.ts b/src/io/interactiveTerminal.ts index e9204c569..0847ba4e0 100644 --- a/src/io/interactiveTerminal.ts +++ b/src/io/interactiveTerminal.ts @@ -1,14 +1,20 @@ import type { AppIO } from "./types"; +const DETACH_BYTE = 0x1d; + export type TerminalFrame = { type: "stdout"; data: Uint8Array } | { type: "stderr"; data: Uint8Array }; export interface InteractiveTerminalPeer extends AsyncIterable { send(data: Uint8Array): Promise; resize(columns: number, rows: number): Promise; - close(): Promise; + detach(): Promise; } +export type InteractiveTerminalResult = { + detached: boolean; +}; + export type InteractiveTerminalConfig = { io: AppIO; dimensions?: () => { columns: number; rows: number }; @@ -35,13 +41,16 @@ export class InteractiveTerminal { }); } - async run(peer: InteractiveTerminalPeer, signal?: AbortSignal): Promise { + async run( + peer: InteractiveTerminalPeer, + signal?: AbortSignal, + ): Promise { if (this.stopCurrent) throw new Error("InteractiveTerminal is already running"); const { stdin, stdout, stderr } = this.config.io; const wasPaused = stdin.isPaused(); const wasRaw = (stdin as NodeJS.ReadStream & { isRaw?: boolean }).isRaw ?? false; - let closed = false; + let detached = false; let fail: (error: unknown) => void = () => {}; let queuedFailure: unknown; let hasQueuedFailure = false; @@ -56,15 +65,19 @@ export class InteractiveTerminal { fail(error); }); }; - const close = async () => { - if (closed) return; - closed = true; - await peer.close(); + const detach = async () => { + if (detached) return; + detached = true; + await peer.detach(); }; - this.stopCurrent = close; + this.stopCurrent = detach; const onData = (chunk: Buffer | string) => { const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8"); + if (bytes.length === 1 && bytes[0] === DETACH_BYTE) { + enqueue(detach); + return; + } enqueue(() => peer.send(bytes)); }; const resize = () => { @@ -74,7 +87,7 @@ export class InteractiveTerminal { const removeResize = this.onResize(resize); const abort = () => { enqueue(async () => { - await close(); + await detach(); throw signal?.reason ?? new Error("terminal interrupted"); }); }; @@ -96,6 +109,7 @@ export class InteractiveTerminal { await Promise.race([pump(), failure]); await pending; if (hasQueuedFailure) throw queuedFailure; + return { detached }; } finally { signal?.removeEventListener("abort", abort); removeResize(); diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index cca896254..32cd07d76 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -674,11 +674,12 @@ export class TestRuntimeClient implements CoreRuntimeClient { private invokeBodies: AsyncIterable[] = []; private shellSession: RuntimeShellSession = { runtimeSessionId: "runtime-session-012345678901234567890123", + shellId: "shell-1", kicked: false, exitCode: 0, send: async () => {}, resize: async () => {}, - close: async () => {}, + detach: async () => {}, async *[Symbol.asyncIterator]() {}, }; private error?: Error;