Skip to content

Commit 27ce2d0

Browse files
authored
feat(invoke,dev): add exec mode for running shell commands in runtimes (#750)
* feat(invoke,dev): add exec mode for running shell commands in runtimes Add ! exec mode to invoke TUI for running shell commands in deployed runtimes, and ! local exec / !! container exec to dev TUI. Includes non-interactive --exec flag for both invoke and dev commands. - invoke TUI: type ! to enter exec mode, runs commands in deployed runtime - invoke CLI: --exec flag runs commands via InvokeAgentRuntimeCommand API - dev TUI: type ! for local exec, !! for container exec (Container builds) - dev CLI: --exec flag execs into running dev container (Container only) - TextInput: add onChange and onBackspaceEmpty props for mode switching - Pink/magenta hint text appears when exec input is empty, disappears on typing - Backspace on empty input reverses mode (!! -> ! -> normal) - IAM policy and docs updated with new bedrock-agentcore:InvokeRuntimeCommand Constraint: dev --exec CLI is Container-only since CodeZip users have local terminal Rejected: Ctrl+E hotkey for container exec | !! double-bang is more discoverable and consistent Confidence: high Scope-risk: moderate * fix: address review findings — side effects, DRY, dead code, validation CRITICAL: Move onBackspaceEmpty callback outside setState updater (React purity violation) and add text.length === 0 guard so it only fires when input is truly empty. HIGH: Extract shared runSpawnCommand helper in useDevServer to DRY up execCommand/execInContainer near-duplication (~100 lines → ~60 lines). Deslop: Remove dead providerInfo/modelProvider code paths, simplify singleValueStream to plain yield, replace options.prompt! assertions with early guard, remove redundant comments. Medium: Fix timeout:0 falsy check, add --exec+--stream validation, handle undefined exitCode explicitly, add SDK cast comment + runtime guard. Constraint: onBackspaceEmpty must fire outside setState to avoid double-fire in React 18 concurrent mode Rejected: Checking state inside updater for callback | React purity violation Confidence: high Scope-risk: narrow * feat(tui): sticky exec mode with distinct visual styling - Exec prompt (!) now uses magenta instead of yellow for clear differentiation from chat prompt (>) - Exec output renders in default terminal foreground, distinct from green chat responses — works on both dark and light terminals - Exec mode is sticky: ! prompt persists after running a command, exit via Escape or Backspace-on-empty - Conversation rendering upgraded to per-line colored output in InvokeScreen (matching DevScreen pattern) Constraint: Terminal color palette limited to 8 base colors Rejected: cyan for exec output | too similar to blue chat input Rejected: yellow for exec output | invisible on light terminal backgrounds Confidence: high Scope-risk: narrow * fix(tui): resolve 3 exec mode UX bugs from review feedback Bug 1: Escape in exec mode no longer exits the app. The Screen component's useExitHandler is disabled when in input mode (exitEnabled={mode !== 'input'}), so only TextInput's onCancel handles Escape. Escape in exec drops to > prompt; Escape from > goes to chat mode; Escape from chat exits. Bug 2: Dim prompt during command execution now shows ! or !! instead of always showing >, matching the current exec state. Bug 3: execInputEmpty is reset to true after command execution, so typing ! in sticky exec mode correctly escalates to !! (container exec) on container agents. * fix(tui): eliminate command flash during exec mode transitions The !! command and prompt would briefly vanish (replaced by >) when executing a container command, because setMode('chat') fired in a separate render batch from setConversation/setIsStreaming. Fix: add onStart callback to runSpawnCommand that fires in the same synchronous block as the conversation/streaming state updates, so React batches them together. The mode transition and conversation update now render atomically — no flash.
1 parent cdd5a15 commit 27ce2d0

17 files changed

Lines changed: 761 additions & 127 deletions

File tree

docs/PERMISSIONS.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -311,11 +311,12 @@ Required for all deployment operations (`deploy`, `status`, `diff`).
311311

312312
### Agent invocation
313313

314-
| Action | CLI Commands | Purpose |
315-
| --------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------- |
316-
| `bedrock-agentcore:InvokeAgentRuntime` | `invoke` | Invoke deployed agents (HTTP, MCP, and A2A protocols) |
317-
| `bedrock-agentcore:InvokeAgentRuntimeForUser` | `invoke` | Invoke agents with a user ID (requires `X-Amzn-Bedrock-AgentCore-Runtime-User-Id` header) |
318-
| `bedrock-agentcore:StopRuntimeSession` | `invoke` | End an agent runtime session |
314+
| Action | CLI Commands | Purpose |
315+
| --------------------------------------------- | --------------- | ----------------------------------------------------------------------------------------- |
316+
| `bedrock-agentcore:InvokeAgentRuntime` | `invoke` | Invoke deployed agents (HTTP, MCP, and A2A protocols) |
317+
| `bedrock-agentcore:InvokeAgentRuntimeForUser` | `invoke` | Invoke agents with a user ID (requires `X-Amzn-Bedrock-AgentCore-Runtime-User-Id` header) |
318+
| `bedrock-agentcore:InvokeAgentRuntimeCommand` | `invoke --exec` | Execute shell commands in a runtime container |
319+
| `bedrock-agentcore:StopRuntimeSession` | `invoke` | End an agent runtime session |
319320

320321
### Runtime and resource status
321322

docs/commands.md

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -505,20 +505,27 @@ agentcore invoke --json # JSON output
505505

506506
# MCP protocol invoke
507507
agentcore invoke call-tool --tool myTool --input '{"key": "value"}'
508+
509+
# Execute shell commands in the runtime container
510+
agentcore invoke --exec "ls -la /app"
511+
agentcore invoke --exec "python script.py" --timeout 120
512+
agentcore invoke --exec "cat /etc/os-release" --json
508513
```
509514

510-
| Flag | Description |
511-
| ------------------- | -------------------------------------------------------- |
512-
| `[prompt]` | Prompt text (positional argument) |
513-
| `--prompt <text>` | Prompt text (flag, takes precedence over positional) |
514-
| `--agent <name>` | Specific agent |
515-
| `--target <name>` | Deployment target |
516-
| `--session-id <id>` | Continue a specific session |
517-
| `--user-id <id>` | User ID for runtime invocation (default: `default-user`) |
518-
| `--stream` | Stream response in real-time |
519-
| `--tool <name>` | MCP tool name (use with `call-tool` prompt) |
520-
| `--input <json>` | MCP tool arguments as JSON (use with `--tool`) |
521-
| `--json` | JSON output |
515+
| Flag | Description |
516+
| --------------------- | -------------------------------------------------------- |
517+
| `[prompt]` | Prompt text (positional argument) |
518+
| `--prompt <text>` | Prompt text (flag, takes precedence over positional) |
519+
| `--agent <name>` | Specific agent |
520+
| `--target <name>` | Deployment target |
521+
| `--session-id <id>` | Continue a specific session |
522+
| `--user-id <id>` | User ID for runtime invocation (default: `default-user`) |
523+
| `--stream` | Stream response in real-time |
524+
| `--tool <name>` | MCP tool name (use with `call-tool` prompt) |
525+
| `--input <json>` | MCP tool arguments as JSON (use with `--tool`) |
526+
| `--exec` | Execute a shell command in the runtime container |
527+
| `--timeout <seconds>` | Timeout in seconds for `--exec` commands |
528+
| `--json` | JSON output |
522529

523530
---
524531

docs/policies/iam-policy-user.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"Action": [
3737
"bedrock-agentcore:InvokeAgentRuntime",
3838
"bedrock-agentcore:InvokeAgentRuntimeForUser",
39+
"bedrock-agentcore:InvokeAgentRuntimeCommand",
3940
"bedrock-agentcore:Evaluate",
4041
"bedrock-agentcore:StopRuntimeSession"
4142
],

src/cli/aws/agentcore.ts

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
EvaluateCommand,
66
type EvaluationReferenceInput,
77
InvokeAgentRuntimeCommand,
8+
InvokeAgentRuntimeCommandCommand,
89
StopRuntimeSessionCommand,
910
} from '@aws-sdk/client-bedrock-agentcore';
1011
import type { HttpRequest } from '@smithy/protocol-http';
@@ -326,9 +327,7 @@ export async function invokeAgentRuntimeStreaming(options: InvokeAgentRuntimeOpt
326327
buffer += decoded;
327328
fullResponse += decoded;
328329

329-
// Process complete lines from the buffer
330330
const lines = buffer.split('\n');
331-
// Keep the last incomplete line in the buffer
332331
buffer = lines.pop() ?? '';
333332

334333
for (const line of lines) {
@@ -824,8 +823,9 @@ export async function invokeA2ARuntime(options: A2AInvokeOptions, message: strin
824823
}
825824

826825
/** Wrap a single string value as an AsyncGenerator for StreamingInvokeResult compatibility. */
826+
// eslint-disable-next-line @typescript-eslint/require-await
827827
async function* singleValueStream(value: string): AsyncGenerator<string, void, unknown> {
828-
yield await Promise.resolve(value);
828+
yield value;
829829
}
830830

831831
/** Extract text content from A2A JSON-RPC response. Supports both kind:'text' and type:'text' part formats. */
@@ -904,3 +904,89 @@ export async function stopRuntimeSession(options: StopRuntimeSessionOptions): Pr
904904
statusCode: response.statusCode,
905905
};
906906
}
907+
908+
// ---------------------------------------------------------------------------
909+
// Execute Bash: Run shell commands in runtime containers
910+
// ---------------------------------------------------------------------------
911+
912+
export interface ExecuteBashOptions {
913+
region: string;
914+
runtimeArn: string;
915+
command: string;
916+
sessionId?: string;
917+
timeout?: number;
918+
/** Custom headers to forward to the agent runtime */
919+
headers?: Record<string, string>;
920+
/** Bearer token for CUSTOM_JWT auth — not yet supported for exec, will throw */
921+
bearerToken?: string;
922+
}
923+
924+
export interface ExecuteBashStreamEvent {
925+
type: 'start' | 'stdout' | 'stderr' | 'stop';
926+
data?: string;
927+
exitCode?: number;
928+
status?: string;
929+
}
930+
931+
export interface ExecuteBashResult {
932+
stream: AsyncGenerator<ExecuteBashStreamEvent, void, unknown>;
933+
sessionId: string | undefined;
934+
}
935+
936+
/**
937+
* Execute a shell command in a running AgentCore Runtime container.
938+
* Returns a streaming result with stdout/stderr events and exit code.
939+
*/
940+
export async function executeBashCommand(options: ExecuteBashOptions): Promise<ExecuteBashResult> {
941+
if (options.bearerToken) {
942+
throw new Error('Bearer token auth for exec is not yet supported. Use SigV4 credentials.');
943+
}
944+
945+
const client = createAgentCoreClient(options.region, options.headers);
946+
947+
const command = new InvokeAgentRuntimeCommandCommand({
948+
agentRuntimeArn: options.runtimeArn,
949+
runtimeSessionId: options.sessionId,
950+
body: {
951+
command: options.command,
952+
...(options.timeout != null ? { timeout: options.timeout } : {}),
953+
},
954+
});
955+
956+
const response = await client.send(command);
957+
const sessionId = response.runtimeSessionId;
958+
959+
async function* streamEvents(): AsyncGenerator<ExecuteBashStreamEvent, void, unknown> {
960+
if (!response.stream) {
961+
throw new Error('No stream in response from AgentCore Runtime');
962+
}
963+
for await (const event of response.stream) {
964+
// SDK types for InvokeAgentRuntimeCommandCommand stream events are not yet published — cast needed until SDK stabilizes
965+
const chunk = (event as unknown as Record<string, unknown>).chunk as Record<string, unknown> | undefined;
966+
if (!chunk || typeof chunk !== 'object') continue;
967+
968+
if (chunk.contentStart !== undefined) {
969+
yield { type: 'start' };
970+
}
971+
const delta = chunk.contentDelta as { stdout?: string; stderr?: string } | undefined;
972+
if (delta) {
973+
if (delta.stdout) {
974+
yield { type: 'stdout', data: delta.stdout };
975+
}
976+
if (delta.stderr) {
977+
yield { type: 'stderr', data: delta.stderr };
978+
}
979+
}
980+
const stop = chunk.contentStop as { exitCode?: number; status?: string } | undefined;
981+
if (stop) {
982+
yield {
983+
type: 'stop',
984+
exitCode: stop.exitCode,
985+
status: stop.status,
986+
};
987+
}
988+
}
989+
}
990+
991+
return { stream: streamEvents(), sessionId };
992+
}

src/cli/aws/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,17 @@ export {
2626
} from './policy-generation';
2727
export {
2828
DEFAULT_RUNTIME_USER_ID,
29+
executeBashCommand,
2930
invokeA2ARuntime,
3031
invokeAgentRuntime,
3132
invokeAgentRuntimeStreaming,
3233
mcpInitSession,
3334
mcpListTools,
3435
mcpCallTool,
3536
stopRuntimeSession,
37+
type ExecuteBashOptions,
38+
type ExecuteBashResult,
39+
type ExecuteBashStreamEvent,
3640
type InvokeAgentRuntimeOptions,
3741
type InvokeAgentRuntimeResult,
3842
type McpInvokeOptions,

src/cli/commands/dev/command.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { findConfigRoot, getWorkingDirectory, readEnvFile } from '../../../lib';
22
import { getErrorMessage } from '../../errors';
3+
import { detectContainerRuntime } from '../../external-requirements';
34
import { ExecLogger } from '../../logging';
45
import {
56
callMcpTool,
@@ -22,6 +23,7 @@ import { COMMAND_DESCRIPTIONS } from '../../tui/copy';
2223
import { requireProject } from '../../tui/guards';
2324
import { parseHeaderFlags } from '../shared/header-utils';
2425
import type { Command } from '@commander-js/extra-typings';
26+
import { spawn } from 'child_process';
2527
import { Text, render } from 'ink';
2628
import React from 'react';
2729

@@ -140,6 +142,26 @@ async function handleMcpInvoke(
140142
}
141143
}
142144

145+
async function execInContainer(command: string, containerName: string): Promise<void> {
146+
const detection = await detectContainerRuntime();
147+
if (!detection.runtime) {
148+
console.error('Error: No container runtime found (docker, podman, or finch required)');
149+
process.exit(1);
150+
}
151+
return new Promise((resolve, reject) => {
152+
const child = spawn(detection.runtime!.binary, ['exec', containerName, 'bash', '-c', command], {
153+
stdio: 'inherit',
154+
});
155+
child.on('error', reject);
156+
child.on('close', code => {
157+
if (code !== 0 && code !== null) {
158+
process.exit(code);
159+
}
160+
resolve();
161+
});
162+
});
163+
}
164+
143165
export const registerDev = (program: Command) => {
144166
program
145167
.command('dev')
@@ -150,6 +172,7 @@ export const registerDev = (program: Command) => {
150172
.option('-r, --runtime <name>', 'Runtime to run or invoke (required if multiple runtimes)')
151173
.option('-s, --stream', 'Stream response when invoking [non-interactive]')
152174
.option('-l, --logs', 'Run dev server with logs to stdout [non-interactive]')
175+
.option('--exec', 'Execute a shell command in the running dev container (Container agents only) [non-interactive]')
153176
.option('--tool <name>', 'MCP tool name (used with "call-tool" prompt) [non-interactive]')
154177
.option('--input <json>', 'MCP tool arguments as JSON (used with --tool) [non-interactive]')
155178
.option(
@@ -168,6 +191,26 @@ export const registerDev = (program: Command) => {
168191
headers = parseHeaderFlags(opts.header);
169192
}
170193

194+
// Exec mode: run shell command in the dev container
195+
if (opts.exec) {
196+
if (!positionalPrompt) {
197+
console.error('A command is required with --exec. Usage: agentcore dev --exec "whoami"');
198+
process.exit(1);
199+
}
200+
const workingDir = getWorkingDirectory();
201+
const project = await loadProjectConfig(workingDir);
202+
const agentName = opts.runtime ?? project?.runtimes[0]?.name ?? 'unknown';
203+
const targetAgent = project?.runtimes.find(a => a.name === agentName);
204+
if (targetAgent?.build !== 'Container') {
205+
console.error('Error: --exec is only supported for Container build agents.');
206+
console.error('For CodeZip agents, use your terminal to run commands directly.');
207+
process.exit(1);
208+
}
209+
const containerName = `agentcore-dev-${agentName}`.toLowerCase();
210+
await execInContainer(positionalPrompt, containerName);
211+
return;
212+
}
213+
171214
// If a prompt is provided, invoke a running dev server
172215
const invokePrompt = positionalPrompt;
173216
if (invokePrompt !== undefined) {

src/cli/commands/invoke/__tests__/validate.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,32 @@ describe('validateInvokeOptions', () => {
3333
it('returns valid with agentName and targetName', () => {
3434
expect(validateInvokeOptions({ agentName: 'my-agent', targetName: 'default' })).toEqual({ valid: true });
3535
});
36+
37+
it('returns invalid when exec is true but no prompt', () => {
38+
const result = validateInvokeOptions({ exec: true });
39+
expect(result.valid).toBe(false);
40+
expect(result.error).toContain('--exec');
41+
});
42+
43+
it('returns invalid when exec is combined with --tool', () => {
44+
const result = validateInvokeOptions({ exec: true, prompt: 'ls', tool: 'myTool' });
45+
expect(result.valid).toBe(false);
46+
expect(result.error).toContain('--exec cannot be combined');
47+
});
48+
49+
it('returns invalid when exec is combined with --input', () => {
50+
const result = validateInvokeOptions({ exec: true, prompt: 'ls', input: '{}' });
51+
expect(result.valid).toBe(false);
52+
expect(result.error).toContain('--exec cannot be combined');
53+
});
54+
55+
it('returns invalid when exec is combined with --stream', () => {
56+
const result = validateInvokeOptions({ exec: true, prompt: 'ls', stream: true });
57+
expect(result.valid).toBe(false);
58+
expect(result.error).toContain('--exec already streams');
59+
});
60+
61+
it('returns valid with exec and prompt', () => {
62+
expect(validateInvokeOptions({ exec: true, prompt: 'ls -la' })).toEqual({ valid: true });
63+
});
3664
});

0 commit comments

Comments
 (0)