Skip to content
Open
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
86 changes: 86 additions & 0 deletions src/components/ShellHandoff.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useEffect, useRef, useState } from "react";
import { Text, useApp, useInput, useStderr, useStdin, useStdout } from "ink";
import { useLocation, useNavigate } from "react-router";
import { AgentCoreCLIError, SilentCLIError } from "../errors";
import type { AppIO } from "../io";
import { Layout } from "./Layout";
import { Spinner } from "./ui/spinner";

type ShellHandoffProps = {
label: string;
returnPath?: string;
run: (io: AppIO) => Promise<void>;
errorHint?: (error: Error) => string | undefined;
};

export function ShellHandoff({ label, returnPath, run, errorHint }: ShellHandoffProps) {
const { exit, suspendTerminal } = useApp();
const { stdin } = useStdin();
const { stdout } = useStdout();
const { stderr } = useStderr();
const location = useLocation();
const navigate = useNavigate();
const requested = useRef(false);
const [error, setError] = useState<Error | null>(null);
const [attempt, setAttempt] = useState(0);

useInput(
(input, key) => {
if (key.ctrl && input === "c") exit();
else if (key.escape) {
navigate(returnPath ?? location.pathname.slice(0, location.pathname.lastIndexOf("/")), {
replace: true,
});
} else if (input === "r") {
requested.current = false;
setError(null);
setAttempt((current) => current + 1);
}
},
{ isActive: error !== null },
);

useEffect(() => {
if (requested.current) return;
requested.current = true;
void (async () => {
try {
await suspendTerminal(() => run({ stdin, stdout, stderr }));
} catch (caught) {
if (!(caught instanceof SilentCLIError)) {
setError(AgentCoreCLIError.fromError(caught));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this changes the exit code for failed shells. Before, any non-silent error went to exit(error), so renderTuiAt rejected and the CLI exited non-zero. Now we catch it, show it, and the only way out is ctrl+c, which calls exit() with no error (line 29). So e.g. agentcore runtime shell --id X with expired creds, or the very 400 this PR is about, ends up exiting 0, and telemetry logs it as a success.

The test "renderTuiAt propagates unexpected shell failures" was removed too, so nothing catches this anymore.

Maybe hang on to the caught error and pass it through on quit, something like if (key.ctrl && input === "c") exit(error ?? undefined), and the same for esc when there's no returnPath? Would be good to bring back a version of that test as well.

return;
}
if (returnPath === undefined) {
exit(caught);
return;
}
}
if (returnPath === undefined) exit();
else navigate(returnPath, { replace: true });
})();
}, [attempt, exit, navigate, returnPath, run, stderr, stdin, stdout, suspendTerminal]);

if (error) {
const hint = errorHint?.(error);
return (
<Layout
breadcrumb={location.pathname.split("/").filter(Boolean).map(decodeURIComponent)}
keyHints={[
{ key: "r", label: "retry" },
{ key: "esc", label: "back" },
{ key: "ctrl+c", label: "quit" },
]}
>
<Text color="red">Error: {error.message}</Text>
{hint && (
<Text>
{"\n"}
{hint}
</Text>
)}
</Layout>
);
}
return <Spinner label={label} />;
}
21 changes: 18 additions & 3 deletions src/core/runtimeShell.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import { MAX_FRAME_SIZE, ShellChannel } from "bedrock-agentcore/runtime";
import { InputValidationError } from "../errors";
import type { RuntimeShellRequest } from "../handlers/runtime/types";
import {
createRuntimeShellOpener,
Expand Down Expand Up @@ -204,9 +205,9 @@ describe("createRuntimeShellOpener", () => {
expect(delays).toEqual([250, 500]);
});

test("does not retry a non-retryable failure", async () => {
test.each([400, 403])("does not retry an HTTP %s rejection", async (statusCode) => {
let attempts = 0;
const failure = new Error("Server rejected WebSocket connection: HTTP 403");
const failure = new Error(`Server rejected WebSocket connection: HTTP ${statusCode}`);
const opener = createRuntimeShellOpener({
createClient: () => ({
openShell: async () => {
Expand All @@ -217,7 +218,21 @@ describe("createRuntimeShellOpener", () => {
sleep: async () => {},
});

await expect(opener(REQUEST, { region: "us-west-2" })).rejects.toBe(failure);
if (statusCode === 400) {
const error = await opener(REQUEST, { region: "us-west-2" }).catch((error: unknown) => error);
expect(error).toBeInstanceOf(InputValidationError);
expect(error).toMatchObject({
cause: failure,
source: "user",
exitCode: 2,
});
expect((error as Error).message).toContain(
"HTTP 400\n\nIf this Runtime is managed by a harness",
);
expect((error as Error).message).toContain("agentcore harness shell --id <harness-id>");
} else {
await expect(opener(REQUEST, { region: "us-west-2" })).rejects.toBe(failure);
}
expect(attempts).toBe(1);
});
});
8 changes: 8 additions & 0 deletions src/core/runtimeShell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
type ShellSession,
} from "bedrock-agentcore/runtime";
import { Buffer } from "node:buffer";
import { InputValidationError } from "../errors";
import type { RuntimeShellFrame, RuntimeShellSession } from "../handlers/runtime/types";
import { runtimeShellErrorHint } from "../handlers/runtime/shell/error";
import type { OpenRuntimeShell } from "./runtime";
import type { CoreOptions } from "./types";

Expand Down Expand Up @@ -74,6 +76,12 @@ export function createRuntimeShellOpener(config: RuntimeShellOpenerConfig = {}):
const session = await client.openShell(input);
return new RuntimeShellSessionAdapter(session);
} catch (error) {
if (error instanceof Error) {
const hint = runtimeShellErrorHint(error);
if (hint !== undefined) {
throw new InputValidationError(`${error.message}\n\n${hint}`, { cause: error });
}
}
if (attempt >= MAX_ATTEMPTS || !isRetryableUpgrade(error)) throw error;
await sleep(delayMs);
delayMs *= 2;
Expand Down
7 changes: 7 additions & 0 deletions src/handlers/runtime/shell/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function runtimeShellErrorHint(error: Error): string | undefined {
if (error.message !== "Server rejected WebSocket connection: HTTP 400") return undefined;
return (
"If this Runtime is managed by a harness, open its shell with:\n" +
"agentcore harness shell --id <harness-id>"

@notgitika notgitika Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

agentcore harness shell doesn't exist on refactor yet. I think it's coming in #2548? If this merges first, the hint sends people to a command that isn't there. should be fine if our intention is to get the other one in first

);
}
84 changes: 15 additions & 69 deletions src/handlers/runtime/shell/screen.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { useEffect, useRef } from "react";
import { useApp, useStderr, useStdin, useStdout } from "ink";
import { useLocation, useNavigate, useParams } from "react-router";
import { RuntimeEndpointPicker } from "../../../components/RuntimeEndpointPicker";
import { RuntimePicker } from "../../../components/RuntimePicker";
import { Spinner } from "../../../components/ui/spinner";
import { SilentCLIError } from "../../../errors";
import { ShellHandoff } from "../../../components/ShellHandoff";
import type { ScreenProps } from "../../types";
import { RuntimeShellLaunchContextKey } from "./launchContext";
import { runtimeShellErrorHint } from "./error";
import { runRuntimeShell } from "./operation";

type RuntimeShellLocationState = {
Expand Down Expand Up @@ -64,73 +62,21 @@ export function RuntimeShellScreen(props: ScreenProps) {
);
}

const launch = props.ctx.value(RuntimeShellLaunchContextKey);
return (
<RuntimeShellHandoff
{...props}
runtimeId={runtimeId}
qualifier={qualifier}
<ShellHandoff
label={`Opening shell for ${runtimeId} (${qualifier})...`}
returnPath={locationState?.returnPath}
errorHint={runtimeShellErrorHint}
run={(io) =>
runRuntimeShell({
...props,
io,
runtimeId,
qualifier,
launchContext: launch?.runtimeId === runtimeId ? launch : undefined,
})
}
/>
);
}

function RuntimeShellHandoff({
ctx,
core,
runtimeId,
qualifier,
returnPath,
}: ScreenProps & { runtimeId: string; qualifier: string; returnPath?: string }) {
const { exit, suspendTerminal } = useApp();
const { stdin } = useStdin();
const { stdout } = useStdout();
const { stderr } = useStderr();
const navigate = useNavigate();
const requested = useRef(false);
const launchContext = ctx.value(RuntimeShellLaunchContextKey);
const initialContext = launchContext?.runtimeId === runtimeId ? launchContext : undefined;

useEffect(() => {
if (requested.current) return;
requested.current = true;
void (async () => {
try {
await suspendTerminal(() =>
runRuntimeShell({
ctx,
core,
io: { stdin, stdout, stderr },
runtimeId,
qualifier,
launchContext: initialContext,
}),
);
} catch (error) {
if (returnPath === undefined || !(error instanceof SilentCLIError)) {
exit(error);
return;
}
}
if (returnPath === undefined) {
exit();
} else {
navigate(returnPath, { replace: true });
}
})();
}, [
core,
ctx,
exit,
initialContext,
navigate,
qualifier,
returnPath,
runtimeId,
stderr,
stdin,
stdout,
suspendTerminal,
]);

return <Spinner label={`Opening shell for ${runtimeId} (${qualifier})...`} />;
}
63 changes: 57 additions & 6 deletions src/handlers/runtime/shell/shell.screen.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, test } from "bun:test";
import { renderTuiAt } from "../../../tui";
import { createRuntimeShellOpener } from "../../../core/runtimeShell";
import { DebugKey, EndpointKey, JsonKey, RegionKey } from "../../keys";
import { ValueContext } from "../../../router";
import type { RuntimeShellSession } from "../types";
Expand Down Expand Up @@ -146,19 +147,69 @@ describe("RuntimeShellScreen", () => {
expect(streams.stderr()).toContain("Connected");
});

test("renderTuiAt propagates unexpected shell failures", async () => {
test("keeps rejected shell connections in the TUI with retry and endpoint back navigation", async () => {
const value = core();
value.runtime.setError(new Error("shell lookup failed"));
const { streams } = ttyTestIO();
const rejectShell = createRuntimeShellOpener({
createClient: () => ({
openShell: async () => {
throw new Error("Server rejected WebSocket connection: HTTP 400");
},
}),
});
const openShell = value.runtime.openRuntimeShell.bind(value.runtime);
let attempts = 0;
let reported: Error | undefined;
value.runtime.openRuntimeShell = async (request, options) => {
if (++attempts > 2) return openShell(request, options);
try {
return await rejectShell(request, options);
} catch (error) {
reported = error as Error;
throw error;
}
};
const { streams, stdin } = ttyTestIO();
const ctx = ValueContext.EmptyContext()
.withValue(RegionKey, "us-east-1")
.withValue(EndpointKey, undefined)
.withValue(JsonKey, false)
.withValue(DebugKey, false);

await expect(
renderTuiAt("/agentcore/runtime/shell/checkout-AbCdEf1234/prod", ctx, value, streams.io),
).rejects.toThrow("shell lookup failed");
const rendering = renderTuiAt(
"/agentcore/runtime/shell/checkout-AbCdEf1234/prod",
ctx,
value,
streams.io,
);
void rendering.catch(() => {});
try {
const errorText = "HTTP 400";
await waitFor(() => streams.stdout().includes(errorText));
expect(streams.stdout()).toContain("checkout-AbCdEf1234");
expect(streams.stdout()).toContain("retry");
expect(streams.stdout()).toContain("back");
expect(streams.stdout().replace(/\s+/g, "")).toContain(reported!.message.replace(/\s+/g, ""));
expect(streams.stdout().replace(/\s+/g, " ")).toContain(
"If this Runtime is managed by a harness",
);
expect(streams.stdout()).toContain("\nIf this Runtime");
expect(streams.stdout().replace(/\s+/g, " ")).toContain(
"agentcore harness shell --id <harness-id>",
);
const errorsBeforeRetry = streams.stdout().split(errorText).length;
stdin.write("r");
await waitFor(() => attempts === 2);
await waitFor(() => streams.stdout().split(errorText).length > errorsBeforeRetry);
stdin.write("\x1b");
await waitFor(() => streams.stdout().includes("choose an endpoint to open a shell"));
await waitFor(() => streams.stdout().includes("updated UTC"));
expect(value.runtime.calls.some((call) => call.method === "listRuntimes")).toBe(false);
stdin.write("\r");
await waitFor(() => streams.stderr().includes("Session closed"));
expect(attempts).toBe(3);
} finally {
await interruptUntilExit(rendering, stdin);
}
});

test("renderTuiAt returns to a requested origin after the shell ends", async () => {
Expand Down
Loading