From 9546d7b2b42f8a07b11a31839214837b1f6971be Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 25 Mar 2026 16:07:40 -0400 Subject: [PATCH 1/3] feat: add runtime lifecycle configuration (idle timeout and max lifetime) Add --idle-timeout and --max-lifetime flags to create/add commands, with full TUI support in both GenerateWizard (create) and AddAgent (BYO) flows. Values map to lifecycleConfiguration in the agent schema and flow through to the CDK construct. Includes schema validation (60-28800s range, idle <= max), CLI flag validation, E2E test support with AWS API verification, integration tests, and TUI integration tests. --- e2e-tests/e2e-helper.ts | 37 ++ e2e-tests/strands-bedrock.test.ts | 6 +- integ-tests/lifecycle-config.test.ts | 322 +++++++++++++ integ-tests/tui/lifecycle-config.test.ts | 446 ++++++++++++++++++ .../commands/add/__tests__/validate.test.ts | 99 ++++ src/cli/commands/add/types.ts | 2 + src/cli/commands/add/validate.ts | 13 + .../create/__tests__/validate.test.ts | 62 +++ src/cli/commands/create/action.ts | 6 + src/cli/commands/create/command.tsx | 4 + src/cli/commands/create/types.ts | 2 + src/cli/commands/create/validate.ts | 5 + .../shared/__tests__/lifecycle-utils.test.ts | 91 ++++ src/cli/commands/shared/lifecycle-utils.ts | 41 ++ .../generate/__tests__/schema-mapper.test.ts | 58 +++ .../agent/generate/schema-mapper.ts | 10 + src/cli/operations/agent/import/index.ts | 6 + src/cli/primitives/AgentPrimitive.tsx | 22 + src/cli/tui/screens/agent/AddAgentScreen.tsx | 67 ++- src/cli/tui/screens/agent/types.ts | 8 + src/cli/tui/screens/agent/useAddAgent.ts | 14 + .../tui/screens/generate/GenerateWizardUI.tsx | 70 ++- .../__tests__/useGenerateWizard.test.tsx | 4 + src/cli/tui/screens/generate/types.ts | 8 + .../tui/screens/generate/useGenerateWizard.ts | 28 +- .../schemas/__tests__/agent-env.test.ts | 116 +++++ src/schema/schemas/agent-env.ts | 26 + 27 files changed, 1568 insertions(+), 5 deletions(-) create mode 100644 integ-tests/lifecycle-config.test.ts create mode 100644 integ-tests/tui/lifecycle-config.test.ts create mode 100644 src/cli/commands/shared/__tests__/lifecycle-utils.test.ts create mode 100644 src/cli/commands/shared/lifecycle-utils.ts diff --git a/e2e-tests/e2e-helper.ts b/e2e-tests/e2e-helper.ts index 0e403b77..6f924f72 100644 --- a/e2e-tests/e2e-helper.ts +++ b/e2e-tests/e2e-helper.ts @@ -9,6 +9,7 @@ import { import { BedrockAgentCoreControlClient, DeleteApiKeyCredentialProviderCommand, + GetAgentRuntimeCommand, } from '@aws-sdk/client-bedrock-agentcore-control'; import { execSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; @@ -26,6 +27,11 @@ interface E2EConfig { requiredEnvVar?: string; build?: string; memory?: string; + /** Lifecycle configuration to pass via --idle-timeout / --max-lifetime flags. */ + lifecycleConfig?: { + idleTimeout?: number; + maxLifetime?: number; + }; } export function createE2ESuite(cfg: E2EConfig) { @@ -63,6 +69,13 @@ export function createE2ESuite(cfg: E2EConfig) { createArgs.push('--build', cfg.build); } + if (cfg.lifecycleConfig?.idleTimeout !== undefined) { + createArgs.push('--idle-timeout', String(cfg.lifecycleConfig.idleTimeout)); + } + if (cfg.lifecycleConfig?.maxLifetime !== undefined) { + createArgs.push('--max-lifetime', String(cfg.lifecycleConfig.maxLifetime)); + } + // Pass API key so the credential is registered in the project and .env.local const apiKey = cfg.requiredEnvVar ? process.env[cfg.requiredEnvVar] : undefined; if (apiKey) { @@ -262,6 +275,30 @@ export function createE2ESuite(cfg: E2EConfig) { }, 120000 ); + + // ── Lifecycle configuration verification ───────────────────────── + if (cfg.lifecycleConfig) { + it.skipIf(!canRun)( + 'runtime has lifecycle configuration set via AWS API', + async () => { + expect(runtimeId, 'Runtime ID should have been extracted from status').toBeTruthy(); + + // Query the runtime via AWS API to verify lifecycle config + const region = process.env.AWS_REGION ?? 'us-east-1'; + const client = new BedrockAgentCoreControlClient({ region }); + const response = await client.send(new GetAgentRuntimeCommand({ agentRuntimeId: runtimeId })); + + expect(response.lifecycleConfiguration).toBeDefined(); + if (cfg.lifecycleConfig!.idleTimeout !== undefined) { + expect(response.lifecycleConfiguration!.idleRuntimeSessionTimeout).toBe(cfg.lifecycleConfig!.idleTimeout); + } + if (cfg.lifecycleConfig!.maxLifetime !== undefined) { + expect(response.lifecycleConfiguration!.maxLifetime).toBe(cfg.lifecycleConfig!.maxLifetime); + } + }, + 180000 + ); + } }); } diff --git a/e2e-tests/strands-bedrock.test.ts b/e2e-tests/strands-bedrock.test.ts index 2acbe2b0..e9c1b50e 100644 --- a/e2e-tests/strands-bedrock.test.ts +++ b/e2e-tests/strands-bedrock.test.ts @@ -1,3 +1,7 @@ import { createE2ESuite } from './e2e-helper.js'; -createE2ESuite({ framework: 'Strands', modelProvider: 'Bedrock' }); +createE2ESuite({ + framework: 'Strands', + modelProvider: 'Bedrock', + lifecycleConfig: { idleTimeout: 120, maxLifetime: 3600 }, +}); diff --git a/integ-tests/lifecycle-config.test.ts b/integ-tests/lifecycle-config.test.ts new file mode 100644 index 00000000..e6c6bb2d --- /dev/null +++ b/integ-tests/lifecycle-config.test.ts @@ -0,0 +1,322 @@ +import { readProjectConfig, runCLI } from '../src/test-utils/index.js'; +import { randomUUID } from 'node:crypto'; +import { mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +describe('integration: lifecycle configuration', () => { + let testDir: string; + let projectPath: string; + + beforeAll(async () => { + testDir = join(tmpdir(), `agentcore-integ-lifecycle-${randomUUID()}`); + await mkdir(testDir, { recursive: true }); + + const result = await runCLI(['create', '--name', 'LifecycleTest', '--no-agent', '--json'], testDir); + expect(result.exitCode, `setup stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + projectPath = json.projectPath; + }); + + afterAll(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + describe('create with lifecycle flags', () => { + let createDir: string; + + beforeAll(async () => { + createDir = join(tmpdir(), `agentcore-integ-lifecycle-create-${randomUUID()}`); + await mkdir(createDir, { recursive: true }); + }); + + afterAll(async () => { + await rm(createDir, { recursive: true, force: true }); + }); + + it('creates project with --idle-timeout and --max-lifetime', async () => { + const name = `LcCreate${Date.now().toString().slice(-6)}`; + const result = await runCLI( + [ + 'create', + '--name', + name, + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + '--idle-timeout', + '300', + '--max-lifetime', + '7200', + '--json', + ], + createDir + ); + + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const config = await readProjectConfig(json.projectPath); + const agents = config.agents as Record[]; + expect(agents.length).toBe(1); + + const agent = agents[0]!; + const lifecycle = agent.lifecycleConfiguration as Record; + expect(lifecycle).toBeDefined(); + expect(lifecycle.idleRuntimeSessionTimeout).toBe(300); + expect(lifecycle.maxLifetime).toBe(7200); + }); + + it('creates project with only --idle-timeout', async () => { + const name = `LcIdle${Date.now().toString().slice(-6)}`; + const result = await runCLI( + [ + 'create', + '--name', + name, + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + '--idle-timeout', + '600', + '--json', + ], + createDir + ); + + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const config = await readProjectConfig(json.projectPath); + const agents = config.agents as Record[]; + const agent = agents[0]!; + const lifecycle = agent.lifecycleConfiguration as Record; + expect(lifecycle).toBeDefined(); + expect(lifecycle.idleRuntimeSessionTimeout).toBe(600); + expect(lifecycle.maxLifetime).toBeUndefined(); + }); + + it('creates project without lifecycle flags — no lifecycleConfiguration in config', async () => { + const name = `LcNone${Date.now().toString().slice(-6)}`; + const result = await runCLI( + [ + 'create', + '--name', + name, + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + '--json', + ], + createDir + ); + + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const config = await readProjectConfig(json.projectPath); + const agents = config.agents as Record[]; + const agent = agents[0]!; + expect(agent.lifecycleConfiguration).toBeUndefined(); + }); + }); + + describe('add agent with lifecycle flags', () => { + it('adds BYO agent with lifecycle config', async () => { + const name = `LcByo${Date.now().toString().slice(-6)}`; + const result = await runCLI( + [ + 'add', + 'agent', + '--name', + name, + '--type', + 'byo', + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--code-location', + `app/${name}/`, + '--idle-timeout', + '120', + '--max-lifetime', + '3600', + '--json', + ], + projectPath + ); + + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const config = await readProjectConfig(projectPath); + const agents = config.agents as Record[]; + const agent = agents.find(a => a.name === name); + expect(agent).toBeDefined(); + const lifecycle = agent!.lifecycleConfiguration as Record; + expect(lifecycle).toBeDefined(); + expect(lifecycle.idleRuntimeSessionTimeout).toBe(120); + expect(lifecycle.maxLifetime).toBe(3600); + }); + + it('adds template agent with only --max-lifetime', async () => { + const name = `LcTmpl${Date.now().toString().slice(-6)}`; + const result = await runCLI( + [ + 'add', + 'agent', + '--name', + name, + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + '--language', + 'Python', + '--max-lifetime', + '14400', + '--json', + ], + projectPath + ); + + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const config = await readProjectConfig(projectPath); + const agents = config.agents as Record[]; + const agent = agents.find(a => a.name === name); + expect(agent).toBeDefined(); + const lifecycle = agent!.lifecycleConfiguration as Record; + expect(lifecycle).toBeDefined(); + expect(lifecycle.idleRuntimeSessionTimeout).toBeUndefined(); + expect(lifecycle.maxLifetime).toBe(14400); + }); + }); + + describe('validation rejects invalid lifecycle values', () => { + it('rejects idle-timeout below 60', async () => { + const name = `LcLow${Date.now().toString().slice(-6)}`; + const result = await runCLI( + [ + 'add', + 'agent', + '--name', + name, + '--type', + 'byo', + '--language', + 'Python', + '--code-location', + `app/${name}/`, + '--idle-timeout', + '30', + '--json', + ], + projectPath + ); + + expect(result.exitCode).not.toBe(0); + }); + + it('rejects max-lifetime above 28800', async () => { + const name = `LcHigh${Date.now().toString().slice(-6)}`; + const result = await runCLI( + [ + 'add', + 'agent', + '--name', + name, + '--type', + 'byo', + '--language', + 'Python', + '--code-location', + `app/${name}/`, + '--max-lifetime', + '99999', + '--json', + ], + projectPath + ); + + expect(result.exitCode).not.toBe(0); + }); + + it('rejects idle-timeout > max-lifetime', async () => { + const name = `LcCross${Date.now().toString().slice(-6)}`; + const result = await runCLI( + [ + 'add', + 'agent', + '--name', + name, + '--type', + 'byo', + '--language', + 'Python', + '--code-location', + `app/${name}/`, + '--idle-timeout', + '5000', + '--max-lifetime', + '3000', + '--json', + ], + projectPath + ); + + expect(result.exitCode).not.toBe(0); + }); + + it('rejects non-integer idle-timeout', async () => { + const name = `LcFloat${Date.now().toString().slice(-6)}`; + const result = await runCLI( + [ + 'add', + 'agent', + '--name', + name, + '--type', + 'byo', + '--language', + 'Python', + '--code-location', + `app/${name}/`, + '--idle-timeout', + '120.5', + '--json', + ], + projectPath + ); + + expect(result.exitCode).not.toBe(0); + }); + }); +}); diff --git a/integ-tests/tui/lifecycle-config.test.ts b/integ-tests/tui/lifecycle-config.test.ts new file mode 100644 index 00000000..4a3f0071 --- /dev/null +++ b/integ-tests/tui/lifecycle-config.test.ts @@ -0,0 +1,446 @@ +/** + * TUI Integration Test: Lifecycle Configuration in Create and Add Agent flows + * + * Drives the TUI through both: + * 1. `create` wizard (GenerateWizard) — advanced settings with lifecycle config + * 2. `add agent` BYO flow (AddAgentScreen) — advanced settings with lifecycle config + * + * Verifies lifecycle values end up in agentcore.json after confirmation. + */ +import { TuiSession, WaitForTimeoutError } from '../../src/tui-harness/index.js'; +import { createMinimalProjectDir } from './helpers.js'; +import type { MinimalProjectDirResult } from './helpers.js'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; + +// --------------------------------------------------------------------------- +// Paths & Constants +// --------------------------------------------------------------------------- + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const CLI_DIST = join(__dirname, '..', '..', 'dist', 'cli', 'index.mjs'); +const SCREENSHOTS_DIR = '/tmp/tui-test-lifecycle/screenshots'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function saveTextScreenshot(session: TuiSession, name: string): string { + const screen = session.readScreen({ numbered: true }); + const nonEmpty = screen.lines.filter((l: string) => l.trim() !== ''); + const { cols, rows } = screen.dimensions; + const header = `Screenshot: ${name} (${cols}x${rows})`; + const border = '='.repeat(Math.max(header.length, 60)); + const text = `${border}\n${header}\n${border}\n${nonEmpty.join('\n')}\n${border}\n`; + const path = join(SCREENSHOTS_DIR, `${name}.txt`); + writeFileSync(path, text, 'utf-8'); + return path; +} + +function getScreenText(session: TuiSession): string { + return session.readScreen().lines.join('\n'); +} + +async function safeWaitFor(session: TuiSession, pattern: string | RegExp, timeoutMs = 10_000): Promise { + try { + await session.waitFor(pattern, timeoutMs); + return true; + } catch (err) { + if (err instanceof WaitForTimeoutError) { + return false; + } + throw err; + } +} + +const settle = (ms = 400) => new Promise(r => setTimeout(r, ms)); + +function readAgentcoreJson(projectDir: string): Record { + const path = join(projectDir, 'agentcore', 'agentcore.json'); + return JSON.parse(readFileSync(path, 'utf-8')); +} + +// --------------------------------------------------------------------------- +// Test Suite: Create Flow with Lifecycle Config +// --------------------------------------------------------------------------- + +describe('Create Flow: Lifecycle Configuration via TUI', () => { + let session: TuiSession; + let _tmpDir: string; + + beforeAll(() => { + mkdirSync(SCREENSHOTS_DIR, { recursive: true }); + }); + + afterEach(async () => { + if (session?.alive) { + await session.close(); + } + }); + + it('lifecycle config flows from create wizard advanced settings into agentcore.json', async () => { + // Create a temp directory for the new project + const { dir: parentDir, cleanup } = await createMinimalProjectDir({ projectName: 'lifecycle-create-test' }); + + try { + // Launch the CLI in "create" mode directly + session = await TuiSession.launch({ + command: process.execPath, + args: [ + CLI_DIST, + 'create', + '--name', + 'LcTuiCreate', + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + ], + cwd: parentDir, + cols: 120, + rows: 35, + }); + + // Should reach the "Advanced" step (yes/no) + const atAdvanced = await safeWaitFor(session, 'Advanced', 15_000); + if (!atAdvanced) { + saveTextScreenshot(session, 'create-01-advanced-fail'); + } + expect(atAdvanced, 'Should reach Advanced config step').toBe(true); + saveTextScreenshot(session, 'create-01-advanced'); + + // Select "Yes" for advanced (first option) + await session.sendSpecialKey('enter'); + const atNetworkMode = await safeWaitFor(session, 'Network', 5_000); + if (!atNetworkMode) { + saveTextScreenshot(session, 'create-02-network-fail'); + } + expect(atNetworkMode, 'Should reach network mode step').toBe(true); + saveTextScreenshot(session, 'create-02-network'); + + // Select PUBLIC (first option) + await session.sendSpecialKey('enter'); + + // Should reach request header allowlist step + const _atHeaders = await safeWaitFor(session, /header|allowlist/i, 5_000); + saveTextScreenshot(session, 'create-03-headers'); + + // Skip headers (Enter with empty) + await session.sendSpecialKey('enter'); + + // Should reach idle timeout step + const atIdleTimeout = await safeWaitFor(session, /idle.*timeout/i, 5_000); + if (!atIdleTimeout) { + saveTextScreenshot(session, 'create-04-idle-fail'); + } + expect(atIdleTimeout, 'Should reach idle timeout step').toBe(true); + saveTextScreenshot(session, 'create-04-idle-timeout'); + + // Enter idle timeout value: 120 + await session.sendKeys('120'); + await settle(300); + await session.sendSpecialKey('enter'); + + // Should reach max lifetime step + const atMaxLifetime = await safeWaitFor(session, /max.*lifetime/i, 5_000); + if (!atMaxLifetime) { + saveTextScreenshot(session, 'create-05-maxlife-fail'); + } + expect(atMaxLifetime, 'Should reach max lifetime step').toBe(true); + saveTextScreenshot(session, 'create-05-max-lifetime'); + + // Enter max lifetime value: 3600 + await session.sendKeys('3600'); + await settle(300); + await session.sendSpecialKey('enter'); + + // Should reach confirm step + const atConfirm = await safeWaitFor(session, /confirm|review/i, 5_000); + if (!atConfirm) { + saveTextScreenshot(session, 'create-06-confirm-fail'); + } + expect(atConfirm, 'Should reach confirm step').toBe(true); + saveTextScreenshot(session, 'create-06-confirm'); + + // Verify the review screen shows lifecycle values + const reviewText = getScreenText(session); + expect(reviewText).toContain('120'); + expect(reviewText).toContain('3600'); + + // Confirm the creation (press Enter or 'y') + await session.sendKeys('y'); + + // Wait for project creation to complete + const created = await safeWaitFor(session, /created|success|Commands/i, 30_000); + saveTextScreenshot(session, 'create-07-result'); + + if (created) { + // Find the created project directory + const { readdirSync } = await import('node:fs'); + const entries = readdirSync(parentDir); + const projectDirName = entries.find(e => e.startsWith('LcTuiCreate') || e === 'LcTuiCreate'); + if (projectDirName) { + const projectPath = join(parentDir, projectDirName); + const config = readAgentcoreJson(projectPath); + const agents = config.agents as Record[]; + expect(agents.length).toBeGreaterThan(0); + + const agent = agents[0]!; + const lifecycle = agent.lifecycleConfiguration as Record; + expect(lifecycle, 'agentcore.json should have lifecycleConfiguration').toBeDefined(); + expect(lifecycle.idleRuntimeSessionTimeout).toBe(120); + expect(lifecycle.maxLifetime).toBe(3600); + } + } + } finally { + await cleanup(); + } + }, 60_000); +}); + +// --------------------------------------------------------------------------- +// Test Suite: Add Agent BYO Flow with Lifecycle Config +// --------------------------------------------------------------------------- + +describe('Add Agent BYO Flow: Lifecycle Configuration via TUI', () => { + let session: TuiSession; + let projectDir: MinimalProjectDirResult; + + beforeAll(async () => { + mkdirSync(SCREENSHOTS_DIR, { recursive: true }); + projectDir = await createMinimalProjectDir({ projectName: 'lifecycle-byo-test' }); + }); + + afterAll(async () => { + if (session?.alive) { + await session.close(); + } + if (projectDir) { + await projectDir.cleanup(); + } + }); + + it('Step 1: launch TUI and navigate to Add Agent', async () => { + session = await TuiSession.launch({ + command: process.execPath, + args: [CLI_DIST], + cwd: projectDir.dir, + cols: 120, + rows: 35, + }); + + const found = await safeWaitFor(session, 'Commands', 15_000); + if (!found) { + saveTextScreenshot(session, 'byo-01-commands-fail'); + } + expect(found).toBe(true); + saveTextScreenshot(session, 'byo-01-helpscreen'); + + // Filter to "add" and open + await session.sendKeys('add'); + await settle(); + await session.sendSpecialKey('enter'); + + const atAdd = await safeWaitFor(session, 'Add Resource', 5_000); + expect(atAdd).toBe(true); + saveTextScreenshot(session, 'byo-02-add-resource'); + + // Select Agent (first option) + await session.sendSpecialKey('enter'); + + const atAgent = await safeWaitFor(session, /agent|Name/i, 5_000); + expect(atAgent).toBe(true); + saveTextScreenshot(session, 'byo-03-add-agent'); + }); + + it('Step 2: select BYO agent type and enter name', async () => { + // The add agent screen first asks for type: Template or BYO + // Check if we see a type selector + const text = getScreenText(session); + + if (text.includes('Template') || text.includes('BYO') || text.includes('Bring')) { + // Navigate to BYO option (should be second) + await session.sendSpecialKey('down'); + await settle(); + await session.sendSpecialKey('enter'); + await settle(); + } + + // Now should be at name entry + const _atName = await safeWaitFor(session, /name/i, 5_000); + saveTextScreenshot(session, 'byo-04-name'); + + // Enter agent name + await session.sendKeys('ByoLifecycle'); + await settle(300); + await session.sendSpecialKey('enter'); + + await settle(500); + saveTextScreenshot(session, 'byo-05-after-name'); + }); + + it('Step 3: navigate through BYO config to advanced settings', async () => { + // After name, BYO agents ask for: language, buildType, protocol, framework, modelProvider, + // codeLocation, entrypoint, then advanced + + // Language (Python first) + let found = await safeWaitFor(session, /language|Python/i, 5_000); + if (found) { + await session.sendSpecialKey('enter'); // select Python + await settle(); + } + saveTextScreenshot(session, 'byo-06-language'); + + // Build type (CodeZip first) + found = await safeWaitFor(session, /build|CodeZip/i, 3_000); + if (found) { + await session.sendSpecialKey('enter'); // select CodeZip + await settle(); + } + saveTextScreenshot(session, 'byo-07-build'); + + // Protocol (HTTP first) + found = await safeWaitFor(session, /protocol|HTTP/i, 3_000); + if (found) { + await session.sendSpecialKey('enter'); // select HTTP + await settle(); + } + saveTextScreenshot(session, 'byo-08-protocol'); + + // Framework + found = await safeWaitFor(session, /framework|Strands/i, 3_000); + if (found) { + await session.sendSpecialKey('enter'); // select Strands + await settle(); + } + saveTextScreenshot(session, 'byo-09-framework'); + + // Model provider + found = await safeWaitFor(session, /model.*provider|Bedrock/i, 3_000); + if (found) { + await session.sendSpecialKey('enter'); // select Bedrock + await settle(); + } + saveTextScreenshot(session, 'byo-10-model-provider'); + + // Runtime version + found = await safeWaitFor(session, /runtime.*version|PYTHON/i, 3_000); + if (found) { + await session.sendSpecialKey('enter'); + await settle(); + } + saveTextScreenshot(session, 'byo-11-runtime-version'); + + // Code location + found = await safeWaitFor(session, /code.*location|directory/i, 3_000); + if (found) { + await session.sendSpecialKey('enter'); // accept default + await settle(); + } + saveTextScreenshot(session, 'byo-12-code-location'); + + // Entrypoint + found = await safeWaitFor(session, /entrypoint|main/i, 3_000); + if (found) { + await session.sendSpecialKey('enter'); // accept default + await settle(); + } + saveTextScreenshot(session, 'byo-13-entrypoint'); + + // Should reach Advanced + const atAdvanced = await safeWaitFor(session, /advanced/i, 5_000); + saveTextScreenshot(session, 'byo-14-advanced'); + expect(atAdvanced, 'Should reach Advanced config').toBe(true); + }); + + it('Step 4: enable advanced settings and enter lifecycle config', async () => { + // Select "Yes" for advanced config + await session.sendSpecialKey('enter'); + await settle(); + saveTextScreenshot(session, 'byo-15-after-advanced-yes'); + + // Network mode — select PUBLIC + let found = await safeWaitFor(session, /network/i, 5_000); + if (found) { + await session.sendSpecialKey('enter'); + await settle(); + } + saveTextScreenshot(session, 'byo-16-network'); + + // Request header allowlist — skip + found = await safeWaitFor(session, /header|allowlist/i, 3_000); + if (found) { + await session.sendSpecialKey('enter'); + await settle(); + } + saveTextScreenshot(session, 'byo-17-headers'); + + // Idle timeout step + found = await safeWaitFor(session, /idle.*timeout/i, 5_000); + if (!found) { + saveTextScreenshot(session, 'byo-18-idle-fail'); + } + expect(found, 'Should reach idle timeout step').toBe(true); + saveTextScreenshot(session, 'byo-18-idle-timeout'); + + // Enter idle timeout: 600 + await session.sendKeys('600'); + await settle(300); + await session.sendSpecialKey('enter'); + + // Max lifetime step + found = await safeWaitFor(session, /max.*lifetime/i, 5_000); + if (!found) { + saveTextScreenshot(session, 'byo-19-maxlife-fail'); + } + expect(found, 'Should reach max lifetime step').toBe(true); + saveTextScreenshot(session, 'byo-19-max-lifetime'); + + // Enter max lifetime: 14400 + await session.sendKeys('14400'); + await settle(300); + await session.sendSpecialKey('enter'); + + // Should reach confirm/review + found = await safeWaitFor(session, /confirm|review/i, 5_000); + if (!found) { + saveTextScreenshot(session, 'byo-20-confirm-fail'); + } + expect(found, 'Should reach confirm step').toBe(true); + saveTextScreenshot(session, 'byo-20-confirm'); + }); + + it('Step 5: review shows lifecycle values and confirm writes to agentcore.json', async () => { + // Verify the review screen shows lifecycle values + const reviewText = getScreenText(session); + expect(reviewText).toContain('600'); + expect(reviewText).toContain('14400'); + saveTextScreenshot(session, 'byo-21-review-values'); + + // Confirm + await session.sendKeys('y'); + await settle(1000); + + // Wait for the agent to be added + const _done = await safeWaitFor(session, /added|success|Commands/i, 10_000); + saveTextScreenshot(session, 'byo-22-after-confirm'); + + // Read the agentcore.json and verify lifecycle config + const config = readAgentcoreJson(projectDir.dir); + const agents = config.agents as Record[]; + const agent = agents.find((a: Record) => a.name === 'ByoLifecycle'); + expect(agent, 'Agent should be in agentcore.json').toBeDefined(); + + const lifecycle = agent!.lifecycleConfiguration as Record; + expect(lifecycle, 'Should have lifecycleConfiguration in agentcore.json').toBeDefined(); + expect(lifecycle.idleRuntimeSessionTimeout).toBe(600); + expect(lifecycle.maxLifetime).toBe(14400); + }); +}); diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index 89e89742..25522e42 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -1322,3 +1322,102 @@ describe('validateAddAgentOptions - VPC validation', () => { expect(result.error).toContain('only valid with --network-mode VPC'); }); }); + +describe('validateAddAgentOptions - lifecycle configuration', () => { + const baseOptions: AddAgentOptions = { + name: 'TestAgent', + type: 'byo', + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + build: 'CodeZip', + codeLocation: './app/test/', + }; + + it('accepts valid idle-timeout', () => { + const result = validateAddAgentOptions({ ...baseOptions, idleTimeout: 900 }); + expect(result.valid).toBe(true); + }); + + it('accepts valid max-lifetime', () => { + const result = validateAddAgentOptions({ ...baseOptions, maxLifetime: 28800 }); + expect(result.valid).toBe(true); + }); + + it('accepts both when idle <= max', () => { + const result = validateAddAgentOptions({ ...baseOptions, idleTimeout: 600, maxLifetime: 3600 }); + expect(result.valid).toBe(true); + }); + + it('accepts both when idle === max', () => { + const result = validateAddAgentOptions({ ...baseOptions, idleTimeout: 3600, maxLifetime: 3600 }); + expect(result.valid).toBe(true); + }); + + it('rejects idle-timeout below 60', () => { + const result = validateAddAgentOptions({ ...baseOptions, idleTimeout: 59 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout'); + }); + + it('rejects idle-timeout above 28800', () => { + const result = validateAddAgentOptions({ ...baseOptions, idleTimeout: 28801 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout'); + }); + + it('rejects max-lifetime below 60', () => { + const result = validateAddAgentOptions({ ...baseOptions, maxLifetime: 59 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--max-lifetime'); + }); + + it('rejects max-lifetime above 28800', () => { + const result = validateAddAgentOptions({ ...baseOptions, maxLifetime: 28801 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--max-lifetime'); + }); + + it('rejects idle > max', () => { + const result = validateAddAgentOptions({ ...baseOptions, idleTimeout: 5000, maxLifetime: 1000 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout must be <= --max-lifetime'); + }); + + it('passes without lifecycle options (defaults handled server-side)', () => { + const result = validateAddAgentOptions(baseOptions); + expect(result.valid).toBe(true); + }); + + it('accepts lifecycle options for import path', () => { + const importOptions: AddAgentOptions = { + name: 'TestAgent', + type: 'import', + agentId: 'AGENT123', + agentAliasId: 'ALIAS123', + region: 'us-east-1', + framework: 'Strands', + memory: 'none', + idleTimeout: 600, + maxLifetime: 7200, + }; + const result = validateAddAgentOptions(importOptions); + expect(result.valid).toBe(true); + }); + + it('rejects invalid lifecycle for import path', () => { + const importOptions: AddAgentOptions = { + name: 'TestAgent', + type: 'import', + agentId: 'AGENT123', + agentAliasId: 'ALIAS123', + region: 'us-east-1', + framework: 'Strands', + memory: 'none', + idleTimeout: 50000, + }; + const result = validateAddAgentOptions(importOptions); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout'); + }); +}); diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index fb05a1d1..031611dc 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -33,6 +33,8 @@ export interface AddAgentOptions extends VpcOptions { customClaims?: string; clientId?: string; clientSecret?: string; + idleTimeout?: number; + maxLifetime?: number; json?: boolean; } diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 39bb1dc7..11613608 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -14,6 +14,7 @@ import { getSupportedModelProviders, matchEnumValue, } from '../../../schema'; +import { validateLifecycleOptions } from '../shared/lifecycle-utils'; import { validateVpcOptions } from '../shared/vpc-utils'; import { validateJwtAuthorizerOptions } from './auth-options'; import type { @@ -136,6 +137,10 @@ export function validateAddAgentOptions(options: AddAgentOptions): ValidationRes error: `Invalid memory option: ${options.memory}. Use none, shortTerm, or longAndShortTerm`, }; } + // Validate lifecycle configuration for import path + const lcResult = validateLifecycleOptions(options); + if (!lcResult.valid) return lcResult; + // Force import defaults options.modelProvider = 'Bedrock' as typeof options.modelProvider; options.language = 'Python' as typeof options.language; @@ -166,6 +171,10 @@ export function validateAddAgentOptions(options: AddAgentOptions): ValidationRes return { valid: false, error: '--code-location is required for BYO path' }; } + // Validate lifecycle configuration for MCP path + const mcpLcResult = validateLifecycleOptions(options); + if (!mcpLcResult.valid) return mcpLcResult; + return { valid: true }; } @@ -234,6 +243,10 @@ export function validateAddAgentOptions(options: AddAgentOptions): ValidationRes } } + // Validate lifecycle configuration + const lifecycleResult = validateLifecycleOptions(options); + if (!lifecycleResult.valid) return lifecycleResult; + // Validate VPC options const vpcResult = validateVpcOptions(options); if (!vpcResult.valid) { diff --git a/src/cli/commands/create/__tests__/validate.test.ts b/src/cli/commands/create/__tests__/validate.test.ts index e465d44e..57a98206 100644 --- a/src/cli/commands/create/__tests__/validate.test.ts +++ b/src/cli/commands/create/__tests__/validate.test.ts @@ -280,3 +280,65 @@ describe('validateCreateOptions - VPC validation', () => { expect(result.error).toContain('--subnets is required'); }); }); + +describe('validateCreateOptions - lifecycle configuration', () => { + const cwd = join(tmpdir(), `create-lifecycle-${randomUUID()}`); + + const baseOptions = { + name: 'TestProject', + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }; + + it('accepts valid idle-timeout', () => { + const result = validateCreateOptions({ ...baseOptions, idleTimeout: '900' }, cwd); + expect(result.valid).toBe(true); + }); + + it('accepts valid max-lifetime', () => { + const result = validateCreateOptions({ ...baseOptions, maxLifetime: '28800' }, cwd); + expect(result.valid).toBe(true); + }); + + it('accepts both when idle <= max', () => { + const result = validateCreateOptions({ ...baseOptions, idleTimeout: '600', maxLifetime: '3600' }, cwd); + expect(result.valid).toBe(true); + }); + + it('rejects idle-timeout below 60', () => { + const result = validateCreateOptions({ ...baseOptions, idleTimeout: '30' }, cwd); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout'); + }); + + it('rejects max-lifetime above 28800', () => { + const result = validateCreateOptions({ ...baseOptions, maxLifetime: '99999' }, cwd); + expect(result.valid).toBe(false); + expect(result.error).toContain('--max-lifetime'); + }); + + it('rejects idle-timeout > max-lifetime', () => { + const result = validateCreateOptions({ ...baseOptions, idleTimeout: '5000', maxLifetime: '3000' }, cwd); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout must be <= --max-lifetime'); + }); + + it('rejects non-integer idle-timeout', () => { + const result = validateCreateOptions({ ...baseOptions, idleTimeout: '120.5' }, cwd); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout'); + }); + + it('rejects non-numeric idle-timeout', () => { + const result = validateCreateOptions({ ...baseOptions, idleTimeout: 'abc' }, cwd); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout'); + }); + + it('accepts no lifecycle flags', () => { + const result = validateCreateOptions({ ...baseOptions }, cwd); + expect(result.valid).toBe(true); + }); +}); diff --git a/src/cli/commands/create/action.ts b/src/cli/commands/create/action.ts index ac11e14e..f947ccbe 100644 --- a/src/cli/commands/create/action.ts +++ b/src/cli/commands/create/action.ts @@ -140,6 +140,8 @@ export interface CreateWithAgentOptions { agentId?: string; agentAliasId?: string; region?: string; + idleTimeout?: number; + maxLifetime?: number; skipGit?: boolean; skipPythonSetup?: boolean; onProgress?: ProgressCallback; @@ -160,6 +162,8 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P subnets, securityGroups, requestHeaderAllowlist, + idleTimeout, + maxLifetime: maxLifetimeOpt, skipGit, skipPythonSetup, onProgress, @@ -237,6 +241,8 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P subnets, securityGroups, requestHeaderAllowlist, + ...(idleTimeout !== undefined && { idleRuntimeSessionTimeout: idleTimeout }), + ...(maxLifetimeOpt !== undefined && { maxLifetime: maxLifetimeOpt }), }; // Resolve credential strategy FIRST (new project has no existing credentials) diff --git a/src/cli/commands/create/command.tsx b/src/cli/commands/create/command.tsx index 693053b0..8a746416 100644 --- a/src/cli/commands/create/command.tsx +++ b/src/cli/commands/create/command.tsx @@ -136,6 +136,8 @@ async function handleCreateCLI(options: CreateOptions): Promise { networkMode: options.networkMode as NetworkMode | undefined, subnets: parseCommaSeparatedList(options.subnets), securityGroups: parseCommaSeparatedList(options.securityGroups), + idleTimeout: options.idleTimeout ? Number(options.idleTimeout) : undefined, + maxLifetime: options.maxLifetime ? Number(options.maxLifetime) : undefined, skipGit: options.skipGit, skipPythonSetup: options.skipPythonSetup, onProgress, @@ -176,6 +178,8 @@ export const registerCreate = (program: Command) => { .option('--network-mode ', 'Network mode (PUBLIC, VPC) [non-interactive]') .option('--subnets ', 'Comma-separated subnet IDs (required for VPC mode) [non-interactive]') .option('--security-groups ', 'Comma-separated security group IDs (required for VPC mode) [non-interactive]') + .option('--idle-timeout ', 'Idle session timeout in seconds (60-28800) [non-interactive]') + .option('--max-lifetime ', 'Max instance lifetime in seconds (60-28800) [non-interactive]') .option('--output-dir ', 'Output directory (default: current directory) [non-interactive]') .option('--skip-git', 'Skip git repository initialization [non-interactive]') .option('--skip-python-setup', 'Skip Python virtual environment setup [non-interactive]') diff --git a/src/cli/commands/create/types.ts b/src/cli/commands/create/types.ts index b17a767b..c7ab0b78 100644 --- a/src/cli/commands/create/types.ts +++ b/src/cli/commands/create/types.ts @@ -15,6 +15,8 @@ export interface CreateOptions extends VpcOptions { agentId?: string; agentAliasId?: string; region?: string; + idleTimeout?: string; + maxLifetime?: string; outputDir?: string; skipGit?: boolean; skipPythonSetup?: boolean; diff --git a/src/cli/commands/create/validate.ts b/src/cli/commands/create/validate.ts index 6920b0ec..4e7d7910 100644 --- a/src/cli/commands/create/validate.ts +++ b/src/cli/commands/create/validate.ts @@ -10,6 +10,7 @@ import { matchEnumValue, } from '../../../schema'; import type { ProtocolMode } from '../../../schema'; +import { validateLifecycleOptions } from '../shared/lifecycle-utils'; import { validateVpcOptions } from '../shared/vpc-utils'; import type { CreateOptions } from './types'; import { existsSync } from 'fs'; @@ -200,5 +201,9 @@ export function validateCreateOptions(options: CreateOptions, cwd?: string): Val return { valid: false, error: vpcResult.error }; } + // Validate lifecycle configuration + const lifecycleResult = validateLifecycleOptions(options); + if (!lifecycleResult.valid) return lifecycleResult; + return { valid: true }; } diff --git a/src/cli/commands/shared/__tests__/lifecycle-utils.test.ts b/src/cli/commands/shared/__tests__/lifecycle-utils.test.ts new file mode 100644 index 00000000..ac1e9caf --- /dev/null +++ b/src/cli/commands/shared/__tests__/lifecycle-utils.test.ts @@ -0,0 +1,91 @@ +import { validateLifecycleOptions } from '../lifecycle-utils'; +import { describe, expect, it } from 'vitest'; + +describe('validateLifecycleOptions', () => { + it('returns valid when no options are set', () => { + expect(validateLifecycleOptions({})).toEqual({ valid: true }); + }); + + it('accepts valid idleTimeout', () => { + const opts = { idleTimeout: 900 }; + expect(validateLifecycleOptions(opts)).toEqual({ valid: true }); + expect(opts.idleTimeout).toBe(900); + }); + + it('accepts valid maxLifetime', () => { + const opts = { maxLifetime: 3600 }; + expect(validateLifecycleOptions(opts)).toEqual({ valid: true }); + expect(opts.maxLifetime).toBe(3600); + }); + + it('accepts both when idle <= max', () => { + expect(validateLifecycleOptions({ idleTimeout: 600, maxLifetime: 3600 })).toEqual({ valid: true }); + }); + + it('accepts boundary values (60 and 28800)', () => { + expect(validateLifecycleOptions({ idleTimeout: 60, maxLifetime: 28800 })).toEqual({ valid: true }); + }); + + it('accepts equal values', () => { + expect(validateLifecycleOptions({ idleTimeout: 3600, maxLifetime: 3600 })).toEqual({ valid: true }); + }); + + it('rejects idleTimeout below 60', () => { + const result = validateLifecycleOptions({ idleTimeout: 59 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout'); + }); + + it('rejects idleTimeout above 28800', () => { + const result = validateLifecycleOptions({ idleTimeout: 28801 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout'); + }); + + it('rejects maxLifetime below 60', () => { + const result = validateLifecycleOptions({ maxLifetime: 59 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--max-lifetime'); + }); + + it('rejects maxLifetime above 28800', () => { + const result = validateLifecycleOptions({ maxLifetime: 28801 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--max-lifetime'); + }); + + it('rejects idle > max', () => { + const result = validateLifecycleOptions({ idleTimeout: 5000, maxLifetime: 3000 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout must be <= --max-lifetime'); + }); + + it('rejects non-integer idleTimeout', () => { + const result = validateLifecycleOptions({ idleTimeout: 120.5 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout'); + }); + + it('rejects NaN string idleTimeout', () => { + const result = validateLifecycleOptions({ idleTimeout: 'abc' as unknown as number }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout'); + }); + + it('rejects NaN string maxLifetime', () => { + const result = validateLifecycleOptions({ maxLifetime: 'abc' as unknown as number }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--max-lifetime'); + }); + + it('normalizes string values to numbers', () => { + const opts: { idleTimeout?: number | string; maxLifetime?: number | string } = { + idleTimeout: '300', + maxLifetime: '7200', + }; + const result = validateLifecycleOptions(opts); + expect(result.valid).toBe(true); + expect(opts.idleTimeout).toBe(300); + expect(opts.maxLifetime).toBe(7200); + }); +}); diff --git a/src/cli/commands/shared/lifecycle-utils.ts b/src/cli/commands/shared/lifecycle-utils.ts new file mode 100644 index 00000000..684b2345 --- /dev/null +++ b/src/cli/commands/shared/lifecycle-utils.ts @@ -0,0 +1,41 @@ +export interface LifecycleOptions { + idleTimeout?: number | string; + maxLifetime?: number | string; +} + +export interface LifecycleValidationResult { + valid: boolean; + error?: string; +} + +export const LIFECYCLE_MIN = 60; +export const LIFECYCLE_MAX = 28800; + +export function validateLifecycleOptions(options: LifecycleOptions): LifecycleValidationResult { + if (options.idleTimeout !== undefined) { + const val = Number(options.idleTimeout); + if (isNaN(val) || !Number.isInteger(val) || val < LIFECYCLE_MIN || val > LIFECYCLE_MAX) { + return { + valid: false, + error: `--idle-timeout must be an integer between ${LIFECYCLE_MIN} and ${LIFECYCLE_MAX} seconds`, + }; + } + options.idleTimeout = val; + } + if (options.maxLifetime !== undefined) { + const val = Number(options.maxLifetime); + if (isNaN(val) || !Number.isInteger(val) || val < LIFECYCLE_MIN || val > LIFECYCLE_MAX) { + return { + valid: false, + error: `--max-lifetime must be an integer between ${LIFECYCLE_MIN} and ${LIFECYCLE_MAX} seconds`, + }; + } + options.maxLifetime = val; + } + if (options.idleTimeout !== undefined && options.maxLifetime !== undefined) { + if (Number(options.idleTimeout) > Number(options.maxLifetime)) { + return { valid: false, error: '--idle-timeout must be <= --max-lifetime' }; + } + } + return { valid: true }; +} diff --git a/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts b/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts index f9c0cc47..4117534f 100644 --- a/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts +++ b/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts @@ -401,3 +401,61 @@ describe('mapByoConfigToAgent - VPC support', () => { expect(result.networkConfig).toBeUndefined(); }); }); + +describe('mapGenerateConfigToAgent - lifecycleConfiguration', () => { + it('includes lifecycleConfiguration when idleRuntimeSessionTimeout is set', () => { + const result = mapGenerateConfigToAgent({ ...baseConfig, idleRuntimeSessionTimeout: 600 }); + expect(result.lifecycleConfiguration).toEqual({ idleRuntimeSessionTimeout: 600 }); + }); + + it('includes lifecycleConfiguration when maxLifetime is set', () => { + const result = mapGenerateConfigToAgent({ ...baseConfig, maxLifetime: 14400 }); + expect(result.lifecycleConfiguration).toEqual({ maxLifetime: 14400 }); + }); + + it('includes both fields when both are set', () => { + const result = mapGenerateConfigToAgent({ ...baseConfig, idleRuntimeSessionTimeout: 300, maxLifetime: 7200 }); + expect(result.lifecycleConfiguration).toEqual({ idleRuntimeSessionTimeout: 300, maxLifetime: 7200 }); + }); + + it('omits lifecycleConfiguration when neither field is set', () => { + const result = mapGenerateConfigToAgent(baseConfig); + expect(result.lifecycleConfiguration).toBeUndefined(); + }); +}); + +describe('mapByoConfigToAgent - lifecycleConfiguration', () => { + const baseByoConfig = { + name: 'ByoAgent', + agentType: 'byo' as const, + codeLocation: 'app/ByoAgent/', + entrypoint: 'main.py', + language: 'Python' as const, + buildType: 'CodeZip' as const, + protocol: 'HTTP' as const, + framework: 'Strands' as const, + modelProvider: 'Bedrock' as const, + pythonVersion: 'PYTHON_3_12' as const, + memory: 'none' as const, + }; + + it('includes lifecycleConfiguration when idleRuntimeSessionTimeout is set', () => { + const result = mapByoConfigToAgent({ ...baseByoConfig, idleRuntimeSessionTimeout: 900 }); + expect(result.lifecycleConfiguration).toEqual({ idleRuntimeSessionTimeout: 900 }); + }); + + it('includes lifecycleConfiguration when maxLifetime is set', () => { + const result = mapByoConfigToAgent({ ...baseByoConfig, maxLifetime: 28800 }); + expect(result.lifecycleConfiguration).toEqual({ maxLifetime: 28800 }); + }); + + it('includes both fields when both are set', () => { + const result = mapByoConfigToAgent({ ...baseByoConfig, idleRuntimeSessionTimeout: 600, maxLifetime: 3600 }); + expect(result.lifecycleConfiguration).toEqual({ idleRuntimeSessionTimeout: 600, maxLifetime: 3600 }); + }); + + it('omits lifecycleConfiguration when neither field is set', () => { + const result = mapByoConfigToAgent(baseByoConfig); + expect(result.lifecycleConfiguration).toBeUndefined(); + }); +}); diff --git a/src/cli/operations/agent/generate/schema-mapper.ts b/src/cli/operations/agent/generate/schema-mapper.ts index 6d99a24d..6f15deec 100644 --- a/src/cli/operations/agent/generate/schema-mapper.ts +++ b/src/cli/operations/agent/generate/schema-mapper.ts @@ -138,6 +138,16 @@ export function mapGenerateConfigToAgent(config: GenerateConfig): AgentEnvSpec { config.jwtConfig && { authorizerConfiguration: buildAuthorizerConfigFromJwtConfig(config.jwtConfig), }), + ...(config.idleRuntimeSessionTimeout !== undefined || config.maxLifetime !== undefined + ? { + lifecycleConfiguration: { + ...(config.idleRuntimeSessionTimeout !== undefined && { + idleRuntimeSessionTimeout: config.idleRuntimeSessionTimeout, + }), + ...(config.maxLifetime !== undefined && { maxLifetime: config.maxLifetime }), + }, + } + : {}), ...(protocol !== 'MCP' && { modelProvider: config.modelProvider }), // MCP uses mcp.run() which is incompatible with the opentelemetry-instrument wrapper ...(protocol === 'MCP' && { instrumentation: { enableOtel: false } }), diff --git a/src/cli/operations/agent/import/index.ts b/src/cli/operations/agent/import/index.ts index 0ef77fdc..5b13c4c2 100644 --- a/src/cli/operations/agent/import/index.ts +++ b/src/cli/operations/agent/import/index.ts @@ -29,6 +29,8 @@ export interface ExecuteImportAgentParams { configBaseDir: string; authorizerType?: RuntimeAuthorizerType; jwtConfig?: JwtConfigOptions; + idleTimeout?: number; + maxLifetime?: number; } export async function executeImportAgent( @@ -44,6 +46,8 @@ export async function executeImportAgent( configBaseDir, authorizerType, jwtConfig, + idleTimeout, + maxLifetime, } = params; const projectRoot = dirname(configBaseDir); const agentPath = join(projectRoot, APP_DIR, name); @@ -92,6 +96,8 @@ export async function executeImportAgent( protocol: 'HTTP' as const, authorizerType, jwtConfig, + idleRuntimeSessionTimeout: idleTimeout, + maxLifetime, }; await writeAgentToProject(generateConfig, { configBaseDir }); diff --git a/src/cli/primitives/AgentPrimitive.tsx b/src/cli/primitives/AgentPrimitive.tsx index 33785cd5..32ee3680 100644 --- a/src/cli/primitives/AgentPrimitive.tsx +++ b/src/cli/primitives/AgentPrimitive.tsx @@ -64,6 +64,8 @@ export interface AddAgentOptions extends VpcOptions { customClaims?: CustomClaimValidation[]; clientId?: string; clientSecret?: string; + idleTimeout?: number; + maxLifetime?: number; } /** @@ -78,6 +80,15 @@ export class AgentPrimitive extends BasePrimitive> { try { const configBaseDir = findConfigRoot(); @@ -212,6 +223,8 @@ export class AgentPrimitive extends BasePrimitive', 'Custom claim validations as JSON array (for CUSTOM_JWT) [non-interactive]') .option('--client-id ', 'OAuth client ID for agent bearer token [non-interactive]') .option('--client-secret ', 'OAuth client secret [non-interactive]') + .option('--idle-timeout ', 'Idle session timeout in seconds (60-28800) [non-interactive]') + .option('--max-lifetime ', 'Max instance lifetime in seconds (60-28800) [non-interactive]') .option('--json', 'Output as JSON [non-interactive]') .action(async options => { if (!findConfigRoot()) { @@ -264,6 +277,8 @@ export class AgentPrimitive extends BasePrimitive('AWS_IAM'); @@ -249,6 +253,8 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg generateWizard.config.jwtConfig && { jwtConfig: generateWizard.config.jwtConfig, }), + idleRuntimeSessionTimeout: generateWizard.config.idleRuntimeSessionTimeout, + maxLifetime: generateWizard.config.maxLifetime, pythonVersion: DEFAULT_PYTHON_VERSION, memory: generateWizard.config.memory, }; @@ -285,6 +291,8 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg ...networkSteps, 'requestHeaderAllowlist', 'authorizerType', + 'idleTimeout', + 'maxLifetime', ...steps.slice(afterAdvanced), ]; } @@ -352,6 +360,8 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg ...(requestHeaderAllowlist.length > 0 && { requestHeaderAllowlist }), ...(byoAuthorizerType !== 'AWS_IAM' && { authorizerType: byoAuthorizerType }), ...(byoAuthorizerType === 'CUSTOM_JWT' && byoJwtConfig && { jwtConfig: byoJwtConfig }), + ...(byoConfig.idleTimeout && { idleRuntimeSessionTimeout: Number(byoConfig.idleTimeout) }), + ...(byoConfig.maxLifetime && { maxLifetime: Number(byoConfig.maxLifetime) }), pythonVersion: DEFAULT_PYTHON_VERSION, memory: 'none', }; @@ -402,7 +412,14 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg setByoStep('networkMode'); } else { setByoAdvancedSelected(false); - setByoConfig(c => ({ ...c, networkMode: 'PUBLIC' as NetworkMode, subnets: '', securityGroups: '' })); + setByoConfig(c => ({ + ...c, + networkMode: 'PUBLIC' as NetworkMode, + subnets: '', + securityGroups: '', + idleTimeout: '', + maxLifetime: '', + })); setByoStep('confirm'); } }, @@ -702,7 +719,9 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg byoStep === 'apiKey' || byoStep === 'subnets' || byoStep === 'securityGroups' || - byoStep === 'requestHeaderAllowlist' + byoStep === 'requestHeaderAllowlist' || + byoStep === 'idleTimeout' || + byoStep === 'maxLifetime' ) { return HELP_TEXT.TEXT_INPUT; } @@ -1075,6 +1094,48 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg /> )} + {byoStep === 'idleTimeout' && ( + { + if (!value) return true; + const n = Number(value); + if (isNaN(n) || !Number.isInteger(n) || n < 60 || n > 28800) + return 'Must be an integer between 60 and 28800'; + return true; + }} + onSubmit={value => { + setByoConfig(c => ({ ...c, idleTimeout: value })); + setByoStep('maxLifetime'); + }} + onCancel={handleByoBack} + /> + )} + + {byoStep === 'maxLifetime' && ( + { + if (!value) return true; + const n = Number(value); + if (isNaN(n) || !Number.isInteger(n) || n < 60 || n > 28800) + return 'Must be an integer between 60 and 28800'; + if (byoConfig.idleTimeout) { + const idle = Number(byoConfig.idleTimeout); + if (!isNaN(idle) && n < idle) return 'Must be >= idle timeout'; + } + return true; + }} + onSubmit={value => { + setByoConfig(c => ({ ...c, maxLifetime: value })); + setByoStep('confirm'); + }} + onCancel={handleByoBack} + /> + )} + {byoStep === 'confirm' && ( )} diff --git a/src/cli/tui/screens/agent/types.ts b/src/cli/tui/screens/agent/types.ts index ef7a6acd..0675e560 100644 --- a/src/cli/tui/screens/agent/types.ts +++ b/src/cli/tui/screens/agent/types.ts @@ -52,6 +52,8 @@ export type AddAgentStep = | 'requestHeaderAllowlist' | 'authorizerType' | 'jwtConfig' + | 'idleTimeout' + | 'maxLifetime' | 'memory' | 'region' | 'bedrockAgent' @@ -85,6 +87,10 @@ export interface AddAgentConfig { authorizerType?: RuntimeAuthorizerType; /** JWT config for CUSTOM_JWT authorizer */ jwtConfig?: JwtConfigOptions; + /** Idle session timeout in seconds (60-28800) */ + idleRuntimeSessionTimeout?: number; + /** Max instance lifetime in seconds (60-28800) */ + maxLifetime?: number; /** Python version (only for Python agents) */ pythonVersion: PythonRuntime; /** Memory option (create path only) */ @@ -114,6 +120,8 @@ export const ADD_AGENT_STEP_LABELS: Record = { requestHeaderAllowlist: 'Headers', authorizerType: 'Auth', jwtConfig: 'JWT Config', + idleTimeout: 'Idle Timeout', + maxLifetime: 'Max Lifetime', memory: 'Memory', region: 'Region', bedrockAgent: 'Agent', diff --git a/src/cli/tui/screens/agent/useAddAgent.ts b/src/cli/tui/screens/agent/useAddAgent.ts index 894aec19..951ec4d1 100644 --- a/src/cli/tui/screens/agent/useAddAgent.ts +++ b/src/cli/tui/screens/agent/useAddAgent.ts @@ -80,6 +80,16 @@ export function mapByoConfigToAgent(config: AddAgentConfig): AgentEnvSpec { config.jwtConfig && { authorizerConfiguration: buildAuthorizerConfigFromJwtConfig(config.jwtConfig), }), + ...(config.idleRuntimeSessionTimeout !== undefined || config.maxLifetime !== undefined + ? { + lifecycleConfiguration: { + ...(config.idleRuntimeSessionTimeout !== undefined && { + idleRuntimeSessionTimeout: config.idleRuntimeSessionTimeout, + }), + ...(config.maxLifetime !== undefined && { maxLifetime: config.maxLifetime }), + }, + } + : {}), }; } @@ -101,6 +111,8 @@ function mapAddAgentConfigToGenerateConfig(config: AddAgentConfig): GenerateConf requestHeaderAllowlist: config.requestHeaderAllowlist, authorizerType: config.authorizerType, jwtConfig: config.jwtConfig, + idleRuntimeSessionTimeout: config.idleRuntimeSessionTimeout, + maxLifetime: config.maxLifetime, }; } @@ -263,6 +275,8 @@ async function handleImportPath( configBaseDir, authorizerType: config.authorizerType, jwtConfig: config.jwtConfig, + idleTimeout: config.idleRuntimeSessionTimeout, + maxLifetime: config.maxLifetime, }); if (!result.success) { diff --git a/src/cli/tui/screens/generate/GenerateWizardUI.tsx b/src/cli/tui/screens/generate/GenerateWizardUI.tsx index 4805d89a..82a8e665 100644 --- a/src/cli/tui/screens/generate/GenerateWizardUI.tsx +++ b/src/cli/tui/screens/generate/GenerateWizardUI.tsx @@ -102,6 +102,8 @@ export function GenerateWizardUI({ const isSecurityGroupsStep = wizard.step === 'securityGroups'; const isRequestHeaderAllowlistStep = wizard.step === 'requestHeaderAllowlist'; const isJwtConfigStep = wizard.step === 'jwtConfig'; + const isIdleTimeoutStep = wizard.step === 'idleTimeout'; + const isMaxLifetimeStep = wizard.step === 'maxLifetime'; const isConfirmStep = wizard.step === 'confirm'; const handleSelect = (item: SelectableItem) => { @@ -285,6 +287,53 @@ export function GenerateWizardUI({ /> )} + {isIdleTimeoutStep && ( + { + if (!value) return true; + const n = Number(value); + if (isNaN(n) || !Number.isInteger(n) || n < 60 || n > 28800) + return 'Must be an integer between 60 and 28800'; + return true; + }} + onSubmit={value => { + if (value) { + wizard.setIdleTimeout(Number(value)); + } else { + wizard.skipIdleTimeout(); + } + }} + onCancel={onBack} + /> + )} + + {isMaxLifetimeStep && ( + { + if (!value) return true; + const n = Number(value); + if (isNaN(n) || !Number.isInteger(n) || n < 60 || n > 28800) + return 'Must be an integer between 60 and 28800'; + if (wizard.config.idleRuntimeSessionTimeout !== undefined && n < wizard.config.idleRuntimeSessionTimeout) { + return 'Must be >= idle timeout'; + } + return true; + }} + onSubmit={value => { + if (value) { + wizard.setMaxLifetime(Number(value)); + } else { + wizard.skipMaxLifetime(); + } + }} + onCancel={onBack} + /> + )} + {isConfirmStep && } ); @@ -296,7 +345,14 @@ export function GenerateWizardUI({ // eslint-disable-next-line react-refresh/only-export-components export function getWizardHelpText(step: GenerateStep): string { if (step === 'confirm') return 'Enter/Y confirm · Esc back'; - if (step === 'projectName' || step === 'subnets' || step === 'securityGroups' || step === 'requestHeaderAllowlist') + if ( + step === 'projectName' || + step === 'subnets' || + step === 'securityGroups' || + step === 'requestHeaderAllowlist' || + step === 'idleTimeout' || + step === 'maxLifetime' + ) return 'Enter submit · Esc cancel'; if (step === 'apiKey') return 'Enter submit · Tab show/hide · Esc back'; if (step === 'jwtConfig') return 'Enter submit · Esc back'; @@ -424,6 +480,18 @@ function ConfirmView({ config, credentialProjectName }: { config: GenerateConfig )} )} + {config.idleRuntimeSessionTimeout !== undefined && ( + + Idle Timeout: + {config.idleRuntimeSessionTimeout}s + + )} + {config.maxLifetime !== undefined && ( + + Max Lifetime: + {config.maxLifetime}s + + )} ); diff --git a/src/cli/tui/screens/generate/__tests__/useGenerateWizard.test.tsx b/src/cli/tui/screens/generate/__tests__/useGenerateWizard.test.tsx index 78a080ee..cbaeb008 100644 --- a/src/cli/tui/screens/generate/__tests__/useGenerateWizard.test.tsx +++ b/src/cli/tui/screens/generate/__tests__/useGenerateWizard.test.tsx @@ -126,6 +126,8 @@ describe('useGenerateWizard — advanced config gate', () => { 'networkMode', 'requestHeaderAllowlist', 'authorizerType', + 'idleTimeout', + 'maxLifetime', 'confirm', ]); }); @@ -146,6 +148,8 @@ describe('useGenerateWizard — advanced config gate', () => { 'securityGroups', 'requestHeaderAllowlist', 'authorizerType', + 'idleTimeout', + 'maxLifetime', 'confirm', ]); }); diff --git a/src/cli/tui/screens/generate/types.ts b/src/cli/tui/screens/generate/types.ts index 2967c406..c989c039 100644 --- a/src/cli/tui/screens/generate/types.ts +++ b/src/cli/tui/screens/generate/types.ts @@ -26,6 +26,8 @@ export type GenerateStep = | 'requestHeaderAllowlist' | 'authorizerType' | 'jwtConfig' + | 'idleTimeout' + | 'maxLifetime' | 'confirm'; export type MemoryOption = 'none' | 'shortTerm' | 'longAndShortTerm'; @@ -52,6 +54,10 @@ export interface GenerateConfig { authorizerType?: RuntimeAuthorizerType; /** JWT config for CUSTOM_JWT authorizer */ jwtConfig?: JwtConfigOptions; + /** Idle session timeout in seconds (60-28800) */ + idleRuntimeSessionTimeout?: number; + /** Max instance lifetime in seconds (60-28800) */ + maxLifetime?: number; } /** Base steps - apiKey, memory, subnets, securityGroups are conditionally added based on selections */ @@ -83,6 +89,8 @@ export const STEP_LABELS: Record = { requestHeaderAllowlist: 'Headers', authorizerType: 'Auth', jwtConfig: 'JWT Config', + idleTimeout: 'Idle Timeout', + maxLifetime: 'Max Lifetime', confirm: 'Confirm', }; diff --git a/src/cli/tui/screens/generate/useGenerateWizard.ts b/src/cli/tui/screens/generate/useGenerateWizard.ts index 0eb9a929..4808ab22 100644 --- a/src/cli/tui/screens/generate/useGenerateWizard.ts +++ b/src/cli/tui/screens/generate/useGenerateWizard.ts @@ -66,6 +66,8 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { ...networkSteps, 'requestHeaderAllowlist', 'authorizerType', + 'idleTimeout', + 'maxLifetime', ...filtered.slice(afterAdvanced), ]; } @@ -185,6 +187,8 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { subnets: undefined, securityGroups: undefined, requestHeaderAllowlist: undefined, + idleRuntimeSessionTimeout: undefined, + maxLifetime: undefined, })); setStep('confirm'); } @@ -224,12 +228,30 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { setStep('jwtConfig'); } else { setConfig(c => ({ ...c, authorizerType, jwtConfig: undefined })); - setStep('confirm'); + setStep('idleTimeout'); } }, []); const setJwtConfig = useCallback((jwtConfig: JwtConfigOptions) => { setConfig(c => ({ ...c, jwtConfig })); + setStep('idleTimeout'); + }, []); + + const setIdleTimeout = useCallback((value: number | undefined) => { + setConfig(c => ({ ...c, idleRuntimeSessionTimeout: value })); + setStep('maxLifetime'); + }, []); + + const skipIdleTimeout = useCallback(() => { + setStep('maxLifetime'); + }, []); + + const setMaxLifetime = useCallback((value: number | undefined) => { + setConfig(c => ({ ...c, maxLifetime: value })); + setStep('confirm'); + }, []); + + const skipMaxLifetime = useCallback(() => { setStep('confirm'); }, []); @@ -283,6 +305,10 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { skipRequestHeaderAllowlist, setAuthorizerType, setJwtConfig, + setIdleTimeout, + skipIdleTimeout, + setMaxLifetime, + skipMaxLifetime, goBack, reset, initWithName, diff --git a/src/schema/schemas/__tests__/agent-env.test.ts b/src/schema/schemas/__tests__/agent-env.test.ts index 3240dcf0..8af8a6aa 100644 --- a/src/schema/schemas/__tests__/agent-env.test.ts +++ b/src/schema/schemas/__tests__/agent-env.test.ts @@ -7,6 +7,7 @@ import { EnvVarSchema, GatewayNameSchema, InstrumentationSchema, + LifecycleConfigurationSchema, NetworkConfigSchema, } from '../agent-env.js'; import { describe, expect, it } from 'vitest'; @@ -349,3 +350,118 @@ describe('NetworkConfigSchema', () => { expect(result.success).toBe(false); }); }); + +describe('LifecycleConfigurationSchema', () => { + it('accepts empty object (both fields optional)', () => { + expect(LifecycleConfigurationSchema.safeParse({}).success).toBe(true); + }); + + it('accepts valid idleRuntimeSessionTimeout only', () => { + expect(LifecycleConfigurationSchema.safeParse({ idleRuntimeSessionTimeout: 900 }).success).toBe(true); + }); + + it('accepts valid maxLifetime only', () => { + expect(LifecycleConfigurationSchema.safeParse({ maxLifetime: 28800 }).success).toBe(true); + }); + + it('accepts both fields when idle <= maxLifetime', () => { + expect(LifecycleConfigurationSchema.safeParse({ idleRuntimeSessionTimeout: 900, maxLifetime: 28800 }).success).toBe( + true + ); + }); + + it('accepts both fields when idle === maxLifetime', () => { + expect(LifecycleConfigurationSchema.safeParse({ idleRuntimeSessionTimeout: 3600, maxLifetime: 3600 }).success).toBe( + true + ); + }); + + it('accepts minimum value (60)', () => { + expect(LifecycleConfigurationSchema.safeParse({ idleRuntimeSessionTimeout: 60 }).success).toBe(true); + expect(LifecycleConfigurationSchema.safeParse({ maxLifetime: 60 }).success).toBe(true); + }); + + it('accepts maximum value (28800)', () => { + expect(LifecycleConfigurationSchema.safeParse({ idleRuntimeSessionTimeout: 28800 }).success).toBe(true); + expect(LifecycleConfigurationSchema.safeParse({ maxLifetime: 28800 }).success).toBe(true); + }); + + it('rejects idle > maxLifetime', () => { + const result = LifecycleConfigurationSchema.safeParse({ idleRuntimeSessionTimeout: 10000, maxLifetime: 5000 }); + expect(result.success).toBe(false); + }); + + it('rejects value below minimum (59)', () => { + expect(LifecycleConfigurationSchema.safeParse({ idleRuntimeSessionTimeout: 59 }).success).toBe(false); + expect(LifecycleConfigurationSchema.safeParse({ maxLifetime: 59 }).success).toBe(false); + }); + + it('rejects value above maximum (28801)', () => { + expect(LifecycleConfigurationSchema.safeParse({ idleRuntimeSessionTimeout: 28801 }).success).toBe(false); + expect(LifecycleConfigurationSchema.safeParse({ maxLifetime: 28801 }).success).toBe(false); + }); + + it('rejects non-integer values', () => { + expect(LifecycleConfigurationSchema.safeParse({ idleRuntimeSessionTimeout: 900.5 }).success).toBe(false); + expect(LifecycleConfigurationSchema.safeParse({ maxLifetime: 100.1 }).success).toBe(false); + }); + + it('rejects non-number values', () => { + expect(LifecycleConfigurationSchema.safeParse({ idleRuntimeSessionTimeout: '900' }).success).toBe(false); + }); +}); + +describe('AgentEnvSpecSchema - lifecycleConfiguration', () => { + const validAgent = { + type: 'AgentCoreRuntime', + name: 'TestAgent', + build: 'CodeZip', + entrypoint: 'main.py', + codeLocation: 'app/TestAgent/', + runtimeVersion: 'PYTHON_3_12', + }; + + it('accepts agent without lifecycleConfiguration', () => { + expect(AgentEnvSpecSchema.safeParse(validAgent).success).toBe(true); + }); + + it('accepts agent with valid lifecycleConfiguration', () => { + const result = AgentEnvSpecSchema.safeParse({ + ...validAgent, + lifecycleConfiguration: { idleRuntimeSessionTimeout: 300, maxLifetime: 7200 }, + }); + expect(result.success).toBe(true); + }); + + it('accepts agent with only idleRuntimeSessionTimeout', () => { + const result = AgentEnvSpecSchema.safeParse({ + ...validAgent, + lifecycleConfiguration: { idleRuntimeSessionTimeout: 600 }, + }); + expect(result.success).toBe(true); + }); + + it('accepts agent with only maxLifetime', () => { + const result = AgentEnvSpecSchema.safeParse({ + ...validAgent, + lifecycleConfiguration: { maxLifetime: 14400 }, + }); + expect(result.success).toBe(true); + }); + + it('rejects agent with idle > maxLifetime in lifecycleConfiguration', () => { + const result = AgentEnvSpecSchema.safeParse({ + ...validAgent, + lifecycleConfiguration: { idleRuntimeSessionTimeout: 10000, maxLifetime: 5000 }, + }); + expect(result.success).toBe(false); + }); + + it('omits lifecycleConfiguration from parsed output when not provided', () => { + const result = AgentEnvSpecSchema.safeParse(validAgent); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.lifecycleConfiguration).toBeUndefined(); + } + }); +}); diff --git a/src/schema/schemas/agent-env.ts b/src/schema/schemas/agent-env.ts index 5c966664..f52e075c 100644 --- a/src/schema/schemas/agent-env.ts +++ b/src/schema/schemas/agent-env.ts @@ -141,6 +141,30 @@ export const RequestHeaderAllowlistSchema = z ) .max(MAX_HEADER_ALLOWLIST_SIZE, `Maximum ${MAX_HEADER_ALLOWLIST_SIZE} headers allowed`); +/** + * Lifecycle configuration for runtime sessions. + * Controls idle timeout and max lifetime of runtime instances. + */ +export const LifecycleConfigurationSchema = z + .object({ + /** Idle session timeout in seconds (60-28800). API default: 900s. */ + idleRuntimeSessionTimeout: z.number().int().min(60).max(28800).optional(), + /** Max instance lifetime in seconds (60-28800). API default: 28800s. */ + maxLifetime: z.number().int().min(60).max(28800).optional(), + }) + .superRefine((data, ctx) => { + if (data.idleRuntimeSessionTimeout !== undefined && data.maxLifetime !== undefined) { + if (data.idleRuntimeSessionTimeout > data.maxLifetime) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'idleRuntimeSessionTimeout must be <= maxLifetime', + path: ['idleRuntimeSessionTimeout'], + }); + } + } + }); +export type LifecycleConfiguration = z.infer; + /** * AgentEnvSpec - represents an AgentCore Runtime. * This is a top-level resource in the schema. @@ -172,6 +196,8 @@ export const AgentEnvSpecSchema = z /** Authorizer configuration. Required when authorizerType is CUSTOM_JWT. */ authorizerConfiguration: AuthorizerConfigSchema.optional(), tags: TagsSchema.optional(), + /** Lifecycle configuration for runtime sessions. */ + lifecycleConfiguration: LifecycleConfigurationSchema.optional(), }) .superRefine((data, ctx) => { if (data.networkMode === 'VPC' && !data.networkConfig) { From 655712c7026ae61829ce6475f3ff3453e4e8954d Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 25 Mar 2026 18:03:48 -0400 Subject: [PATCH 2/3] fix: remove lifecycle config from strands-bedrock e2e test The strands-bedrock e2e test reuses an existing deployed runtime that was created without lifecycle config. Adding lifecycle assertions to it causes failures because the already-deployed runtime has default values (900s) rather than the overridden ones (120s). Runtime lifecycle configuration should be validated via a dedicated test, not by modifying shared framework e2e tests. --- e2e-tests/strands-bedrock.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/e2e-tests/strands-bedrock.test.ts b/e2e-tests/strands-bedrock.test.ts index e9c1b50e..212ecedc 100644 --- a/e2e-tests/strands-bedrock.test.ts +++ b/e2e-tests/strands-bedrock.test.ts @@ -3,5 +3,4 @@ import { createE2ESuite } from './e2e-helper.js'; createE2ESuite({ framework: 'Strands', modelProvider: 'Bedrock', - lifecycleConfig: { idleTimeout: 120, maxLifetime: 3600 }, }); From bcc83c4351a13a164a8a12791152aa62353bde25 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Thu, 26 Mar 2026 12:52:26 -0400 Subject: [PATCH 3/3] fix: address review feedback on lifecycle configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract LIFECYCLE_TIMEOUT_MIN/MAX constants from schema, use everywhere instead of hardcoded 60/28800 values (review comment 2) - Rename validateLifecycleOptions → parseAndValidateLifecycleOptions to clarify intent; return parsed values instead of mutating input (comment 3) - Unify create/add option types to number | string for consistency (comment 4) --- src/cli/commands/add/types.ts | 4 +- src/cli/commands/add/validate.ts | 20 +++--- src/cli/commands/create/command.tsx | 11 +++- src/cli/commands/create/types.ts | 4 +- src/cli/commands/create/validate.ts | 8 ++- .../shared/__tests__/lifecycle-utils.test.ts | 61 ++++++++++--------- src/cli/commands/shared/lifecycle-utils.ts | 33 ++++++---- src/cli/primitives/AgentPrimitive.tsx | 12 +++- src/cli/tui/screens/agent/AddAgentScreen.tsx | 14 ++--- src/cli/tui/screens/agent/types.ts | 4 +- .../tui/screens/generate/GenerateWizardUI.tsx | 14 ++--- src/cli/tui/screens/generate/types.ts | 4 +- src/schema/schemas/agent-env.ts | 13 ++-- 13 files changed, 119 insertions(+), 83 deletions(-) diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index 031611dc..21ecd7f9 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -33,8 +33,8 @@ export interface AddAgentOptions extends VpcOptions { customClaims?: string; clientId?: string; clientSecret?: string; - idleTimeout?: number; - maxLifetime?: number; + idleTimeout?: number | string; + maxLifetime?: number | string; json?: boolean; } diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 11613608..e87b1513 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -14,7 +14,7 @@ import { getSupportedModelProviders, matchEnumValue, } from '../../../schema'; -import { validateLifecycleOptions } from '../shared/lifecycle-utils'; +import { parseAndValidateLifecycleOptions } from '../shared/lifecycle-utils'; import { validateVpcOptions } from '../shared/vpc-utils'; import { validateJwtAuthorizerOptions } from './auth-options'; import type { @@ -137,9 +137,11 @@ export function validateAddAgentOptions(options: AddAgentOptions): ValidationRes error: `Invalid memory option: ${options.memory}. Use none, shortTerm, or longAndShortTerm`, }; } - // Validate lifecycle configuration for import path - const lcResult = validateLifecycleOptions(options); + // Parse and validate lifecycle configuration for import path + const lcResult = parseAndValidateLifecycleOptions(options); if (!lcResult.valid) return lcResult; + if (lcResult.idleTimeout !== undefined) options.idleTimeout = lcResult.idleTimeout; + if (lcResult.maxLifetime !== undefined) options.maxLifetime = lcResult.maxLifetime; // Force import defaults options.modelProvider = 'Bedrock' as typeof options.modelProvider; @@ -171,9 +173,11 @@ export function validateAddAgentOptions(options: AddAgentOptions): ValidationRes return { valid: false, error: '--code-location is required for BYO path' }; } - // Validate lifecycle configuration for MCP path - const mcpLcResult = validateLifecycleOptions(options); + // Parse and validate lifecycle configuration for MCP path + const mcpLcResult = parseAndValidateLifecycleOptions(options); if (!mcpLcResult.valid) return mcpLcResult; + if (mcpLcResult.idleTimeout !== undefined) options.idleTimeout = mcpLcResult.idleTimeout; + if (mcpLcResult.maxLifetime !== undefined) options.maxLifetime = mcpLcResult.maxLifetime; return { valid: true }; } @@ -243,9 +247,11 @@ export function validateAddAgentOptions(options: AddAgentOptions): ValidationRes } } - // Validate lifecycle configuration - const lifecycleResult = validateLifecycleOptions(options); + // Parse and validate lifecycle configuration + const lifecycleResult = parseAndValidateLifecycleOptions(options); if (!lifecycleResult.valid) return lifecycleResult; + if (lifecycleResult.idleTimeout !== undefined) options.idleTimeout = lifecycleResult.idleTimeout; + if (lifecycleResult.maxLifetime !== undefined) options.maxLifetime = lifecycleResult.maxLifetime; // Validate VPC options const vpcResult = validateVpcOptions(options); diff --git a/src/cli/commands/create/command.tsx b/src/cli/commands/create/command.tsx index 8a746416..5c0a0b47 100644 --- a/src/cli/commands/create/command.tsx +++ b/src/cli/commands/create/command.tsx @@ -7,6 +7,7 @@ import type { SDKFramework, TargetLanguage, } from '../../../schema'; +import { LIFECYCLE_TIMEOUT_MAX, LIFECYCLE_TIMEOUT_MIN } from '../../../schema'; import { getErrorMessage } from '../../errors'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { CreateScreen } from '../../tui/screens/create'; @@ -178,8 +179,14 @@ export const registerCreate = (program: Command) => { .option('--network-mode ', 'Network mode (PUBLIC, VPC) [non-interactive]') .option('--subnets ', 'Comma-separated subnet IDs (required for VPC mode) [non-interactive]') .option('--security-groups ', 'Comma-separated security group IDs (required for VPC mode) [non-interactive]') - .option('--idle-timeout ', 'Idle session timeout in seconds (60-28800) [non-interactive]') - .option('--max-lifetime ', 'Max instance lifetime in seconds (60-28800) [non-interactive]') + .option( + '--idle-timeout ', + `Idle session timeout in seconds (${LIFECYCLE_TIMEOUT_MIN}-${LIFECYCLE_TIMEOUT_MAX}) [non-interactive]` + ) + .option( + '--max-lifetime ', + `Max instance lifetime in seconds (${LIFECYCLE_TIMEOUT_MIN}-${LIFECYCLE_TIMEOUT_MAX}) [non-interactive]` + ) .option('--output-dir ', 'Output directory (default: current directory) [non-interactive]') .option('--skip-git', 'Skip git repository initialization [non-interactive]') .option('--skip-python-setup', 'Skip Python virtual environment setup [non-interactive]') diff --git a/src/cli/commands/create/types.ts b/src/cli/commands/create/types.ts index c7ab0b78..fd9fb13a 100644 --- a/src/cli/commands/create/types.ts +++ b/src/cli/commands/create/types.ts @@ -15,8 +15,8 @@ export interface CreateOptions extends VpcOptions { agentId?: string; agentAliasId?: string; region?: string; - idleTimeout?: string; - maxLifetime?: string; + idleTimeout?: number | string; + maxLifetime?: number | string; outputDir?: string; skipGit?: boolean; skipPythonSetup?: boolean; diff --git a/src/cli/commands/create/validate.ts b/src/cli/commands/create/validate.ts index 4e7d7910..b4feec35 100644 --- a/src/cli/commands/create/validate.ts +++ b/src/cli/commands/create/validate.ts @@ -10,7 +10,7 @@ import { matchEnumValue, } from '../../../schema'; import type { ProtocolMode } from '../../../schema'; -import { validateLifecycleOptions } from '../shared/lifecycle-utils'; +import { parseAndValidateLifecycleOptions } from '../shared/lifecycle-utils'; import { validateVpcOptions } from '../shared/vpc-utils'; import type { CreateOptions } from './types'; import { existsSync } from 'fs'; @@ -201,9 +201,11 @@ export function validateCreateOptions(options: CreateOptions, cwd?: string): Val return { valid: false, error: vpcResult.error }; } - // Validate lifecycle configuration - const lifecycleResult = validateLifecycleOptions(options); + // Parse and validate lifecycle configuration + const lifecycleResult = parseAndValidateLifecycleOptions(options); if (!lifecycleResult.valid) return lifecycleResult; + if (lifecycleResult.idleTimeout !== undefined) options.idleTimeout = lifecycleResult.idleTimeout; + if (lifecycleResult.maxLifetime !== undefined) options.maxLifetime = lifecycleResult.maxLifetime; return { valid: true }; } diff --git a/src/cli/commands/shared/__tests__/lifecycle-utils.test.ts b/src/cli/commands/shared/__tests__/lifecycle-utils.test.ts index ac1e9caf..03f02c68 100644 --- a/src/cli/commands/shared/__tests__/lifecycle-utils.test.ts +++ b/src/cli/commands/shared/__tests__/lifecycle-utils.test.ts @@ -1,91 +1,92 @@ -import { validateLifecycleOptions } from '../lifecycle-utils'; +import { parseAndValidateLifecycleOptions } from '../lifecycle-utils'; import { describe, expect, it } from 'vitest'; -describe('validateLifecycleOptions', () => { +describe('parseAndValidateLifecycleOptions', () => { it('returns valid when no options are set', () => { - expect(validateLifecycleOptions({})).toEqual({ valid: true }); + expect(parseAndValidateLifecycleOptions({})).toEqual({ valid: true }); }); - it('accepts valid idleTimeout', () => { - const opts = { idleTimeout: 900 }; - expect(validateLifecycleOptions(opts)).toEqual({ valid: true }); - expect(opts.idleTimeout).toBe(900); + it('accepts valid idleTimeout and returns parsed value', () => { + const result = parseAndValidateLifecycleOptions({ idleTimeout: 900 }); + expect(result).toEqual({ valid: true, idleTimeout: 900 }); }); - it('accepts valid maxLifetime', () => { - const opts = { maxLifetime: 3600 }; - expect(validateLifecycleOptions(opts)).toEqual({ valid: true }); - expect(opts.maxLifetime).toBe(3600); + it('accepts valid maxLifetime and returns parsed value', () => { + const result = parseAndValidateLifecycleOptions({ maxLifetime: 3600 }); + expect(result).toEqual({ valid: true, maxLifetime: 3600 }); }); it('accepts both when idle <= max', () => { - expect(validateLifecycleOptions({ idleTimeout: 600, maxLifetime: 3600 })).toEqual({ valid: true }); + const result = parseAndValidateLifecycleOptions({ idleTimeout: 600, maxLifetime: 3600 }); + expect(result).toEqual({ valid: true, idleTimeout: 600, maxLifetime: 3600 }); }); it('accepts boundary values (60 and 28800)', () => { - expect(validateLifecycleOptions({ idleTimeout: 60, maxLifetime: 28800 })).toEqual({ valid: true }); + const result = parseAndValidateLifecycleOptions({ idleTimeout: 60, maxLifetime: 28800 }); + expect(result).toEqual({ valid: true, idleTimeout: 60, maxLifetime: 28800 }); }); it('accepts equal values', () => { - expect(validateLifecycleOptions({ idleTimeout: 3600, maxLifetime: 3600 })).toEqual({ valid: true }); + const result = parseAndValidateLifecycleOptions({ idleTimeout: 3600, maxLifetime: 3600 }); + expect(result).toEqual({ valid: true, idleTimeout: 3600, maxLifetime: 3600 }); }); it('rejects idleTimeout below 60', () => { - const result = validateLifecycleOptions({ idleTimeout: 59 }); + const result = parseAndValidateLifecycleOptions({ idleTimeout: 59 }); expect(result.valid).toBe(false); expect(result.error).toContain('--idle-timeout'); }); it('rejects idleTimeout above 28800', () => { - const result = validateLifecycleOptions({ idleTimeout: 28801 }); + const result = parseAndValidateLifecycleOptions({ idleTimeout: 28801 }); expect(result.valid).toBe(false); expect(result.error).toContain('--idle-timeout'); }); it('rejects maxLifetime below 60', () => { - const result = validateLifecycleOptions({ maxLifetime: 59 }); + const result = parseAndValidateLifecycleOptions({ maxLifetime: 59 }); expect(result.valid).toBe(false); expect(result.error).toContain('--max-lifetime'); }); it('rejects maxLifetime above 28800', () => { - const result = validateLifecycleOptions({ maxLifetime: 28801 }); + const result = parseAndValidateLifecycleOptions({ maxLifetime: 28801 }); expect(result.valid).toBe(false); expect(result.error).toContain('--max-lifetime'); }); it('rejects idle > max', () => { - const result = validateLifecycleOptions({ idleTimeout: 5000, maxLifetime: 3000 }); + const result = parseAndValidateLifecycleOptions({ idleTimeout: 5000, maxLifetime: 3000 }); expect(result.valid).toBe(false); expect(result.error).toContain('--idle-timeout must be <= --max-lifetime'); }); it('rejects non-integer idleTimeout', () => { - const result = validateLifecycleOptions({ idleTimeout: 120.5 }); + const result = parseAndValidateLifecycleOptions({ idleTimeout: 120.5 }); expect(result.valid).toBe(false); expect(result.error).toContain('--idle-timeout'); }); it('rejects NaN string idleTimeout', () => { - const result = validateLifecycleOptions({ idleTimeout: 'abc' as unknown as number }); + const result = parseAndValidateLifecycleOptions({ idleTimeout: 'abc' }); expect(result.valid).toBe(false); expect(result.error).toContain('--idle-timeout'); }); it('rejects NaN string maxLifetime', () => { - const result = validateLifecycleOptions({ maxLifetime: 'abc' as unknown as number }); + const result = parseAndValidateLifecycleOptions({ maxLifetime: 'abc' }); expect(result.valid).toBe(false); expect(result.error).toContain('--max-lifetime'); }); - it('normalizes string values to numbers', () => { - const opts: { idleTimeout?: number | string; maxLifetime?: number | string } = { - idleTimeout: '300', - maxLifetime: '7200', - }; - const result = validateLifecycleOptions(opts); + it('parses string values to numbers without mutating input', () => { + const opts = { idleTimeout: '300', maxLifetime: '7200' }; + const result = parseAndValidateLifecycleOptions(opts); expect(result.valid).toBe(true); - expect(opts.idleTimeout).toBe(300); - expect(opts.maxLifetime).toBe(7200); + expect(result.idleTimeout).toBe(300); + expect(result.maxLifetime).toBe(7200); + // Original input is NOT mutated + expect(opts.idleTimeout).toBe('300'); + expect(opts.maxLifetime).toBe('7200'); }); }); diff --git a/src/cli/commands/shared/lifecycle-utils.ts b/src/cli/commands/shared/lifecycle-utils.ts index 684b2345..9bcd8d36 100644 --- a/src/cli/commands/shared/lifecycle-utils.ts +++ b/src/cli/commands/shared/lifecycle-utils.ts @@ -1,3 +1,5 @@ +import { LIFECYCLE_TIMEOUT_MAX, LIFECYCLE_TIMEOUT_MIN } from '../../../schema'; + export interface LifecycleOptions { idleTimeout?: number | string; maxLifetime?: number | string; @@ -6,36 +8,43 @@ export interface LifecycleOptions { export interface LifecycleValidationResult { valid: boolean; error?: string; + idleTimeout?: number; + maxLifetime?: number; } -export const LIFECYCLE_MIN = 60; -export const LIFECYCLE_MAX = 28800; +/** + * Parse and validate lifecycle CLI options. + * Coerces string values to numbers and validates range constraints. + * Returns the parsed numeric values in the result — does NOT mutate the input. + */ +export function parseAndValidateLifecycleOptions(options: LifecycleOptions): LifecycleValidationResult { + let idleTimeout: number | undefined; + let maxLifetime: number | undefined; -export function validateLifecycleOptions(options: LifecycleOptions): LifecycleValidationResult { if (options.idleTimeout !== undefined) { const val = Number(options.idleTimeout); - if (isNaN(val) || !Number.isInteger(val) || val < LIFECYCLE_MIN || val > LIFECYCLE_MAX) { + if (isNaN(val) || !Number.isInteger(val) || val < LIFECYCLE_TIMEOUT_MIN || val > LIFECYCLE_TIMEOUT_MAX) { return { valid: false, - error: `--idle-timeout must be an integer between ${LIFECYCLE_MIN} and ${LIFECYCLE_MAX} seconds`, + error: `--idle-timeout must be an integer between ${LIFECYCLE_TIMEOUT_MIN} and ${LIFECYCLE_TIMEOUT_MAX} seconds`, }; } - options.idleTimeout = val; + idleTimeout = val; } if (options.maxLifetime !== undefined) { const val = Number(options.maxLifetime); - if (isNaN(val) || !Number.isInteger(val) || val < LIFECYCLE_MIN || val > LIFECYCLE_MAX) { + if (isNaN(val) || !Number.isInteger(val) || val < LIFECYCLE_TIMEOUT_MIN || val > LIFECYCLE_TIMEOUT_MAX) { return { valid: false, - error: `--max-lifetime must be an integer between ${LIFECYCLE_MIN} and ${LIFECYCLE_MAX} seconds`, + error: `--max-lifetime must be an integer between ${LIFECYCLE_TIMEOUT_MIN} and ${LIFECYCLE_TIMEOUT_MAX} seconds`, }; } - options.maxLifetime = val; + maxLifetime = val; } - if (options.idleTimeout !== undefined && options.maxLifetime !== undefined) { - if (Number(options.idleTimeout) > Number(options.maxLifetime)) { + if (idleTimeout !== undefined && maxLifetime !== undefined) { + if (idleTimeout > maxLifetime) { return { valid: false, error: '--idle-timeout must be <= --max-lifetime' }; } } - return { valid: true }; + return { valid: true, idleTimeout, maxLifetime }; } diff --git a/src/cli/primitives/AgentPrimitive.tsx b/src/cli/primitives/AgentPrimitive.tsx index 32ee3680..07040697 100644 --- a/src/cli/primitives/AgentPrimitive.tsx +++ b/src/cli/primitives/AgentPrimitive.tsx @@ -12,7 +12,7 @@ import type { SDKFramework, TargetLanguage, } from '../../schema'; -import { AgentEnvSpecSchema, CREDENTIAL_PROVIDERS } from '../../schema'; +import { AgentEnvSpecSchema, CREDENTIAL_PROVIDERS, LIFECYCLE_TIMEOUT_MAX, LIFECYCLE_TIMEOUT_MIN } from '../../schema'; import type { AddAgentOptions as CLIAddAgentOptions } from '../commands/add/types'; import { validateAddAgentOptions } from '../commands/add/validate'; import type { VpcOptions } from '../commands/shared/vpc-utils'; @@ -223,8 +223,14 @@ export class AgentPrimitive extends BasePrimitive', 'Custom claim validations as JSON array (for CUSTOM_JWT) [non-interactive]') .option('--client-id ', 'OAuth client ID for agent bearer token [non-interactive]') .option('--client-secret ', 'OAuth client secret [non-interactive]') - .option('--idle-timeout ', 'Idle session timeout in seconds (60-28800) [non-interactive]') - .option('--max-lifetime ', 'Max instance lifetime in seconds (60-28800) [non-interactive]') + .option( + '--idle-timeout ', + `Idle session timeout in seconds (${LIFECYCLE_TIMEOUT_MIN}-${LIFECYCLE_TIMEOUT_MAX}) [non-interactive]` + ) + .option( + '--max-lifetime ', + `Max instance lifetime in seconds (${LIFECYCLE_TIMEOUT_MIN}-${LIFECYCLE_TIMEOUT_MAX}) [non-interactive]` + ) .option('--json', 'Output as JSON [non-interactive]') .action(async options => { if (!findConfigRoot()) { diff --git a/src/cli/tui/screens/agent/AddAgentScreen.tsx b/src/cli/tui/screens/agent/AddAgentScreen.tsx index ab1de605..2b40dd28 100644 --- a/src/cli/tui/screens/agent/AddAgentScreen.tsx +++ b/src/cli/tui/screens/agent/AddAgentScreen.tsx @@ -1,6 +1,6 @@ import { APP_DIR, ConfigIO } from '../../../../lib'; import type { ModelProvider, NetworkMode, RuntimeAuthorizerType, SDKFramework } from '../../../../schema'; -import { AgentNameSchema, DEFAULT_MODEL_IDS } from '../../../../schema'; +import { AgentNameSchema, DEFAULT_MODEL_IDS, LIFECYCLE_TIMEOUT_MAX, LIFECYCLE_TIMEOUT_MIN } from '../../../../schema'; import { listBedrockAgentAliases, listBedrockAgents } from '../../../aws/bedrock-import'; import type { BedrockAgentSummary, BedrockAliasSummary } from '../../../aws/bedrock-import-types'; import { parseAndNormalizeHeaders, validateHeaderAllowlist } from '../../../commands/shared/header-utils'; @@ -1096,13 +1096,13 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg {byoStep === 'idleTimeout' && ( { if (!value) return true; const n = Number(value); - if (isNaN(n) || !Number.isInteger(n) || n < 60 || n > 28800) - return 'Must be an integer between 60 and 28800'; + if (isNaN(n) || !Number.isInteger(n) || n < LIFECYCLE_TIMEOUT_MIN || n > LIFECYCLE_TIMEOUT_MAX) + return `Must be an integer between ${LIFECYCLE_TIMEOUT_MIN} and ${LIFECYCLE_TIMEOUT_MAX}`; return true; }} onSubmit={value => { @@ -1115,13 +1115,13 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg {byoStep === 'maxLifetime' && ( { if (!value) return true; const n = Number(value); - if (isNaN(n) || !Number.isInteger(n) || n < 60 || n > 28800) - return 'Must be an integer between 60 and 28800'; + if (isNaN(n) || !Number.isInteger(n) || n < LIFECYCLE_TIMEOUT_MIN || n > LIFECYCLE_TIMEOUT_MAX) + return `Must be an integer between ${LIFECYCLE_TIMEOUT_MIN} and ${LIFECYCLE_TIMEOUT_MAX}`; if (byoConfig.idleTimeout) { const idle = Number(byoConfig.idleTimeout); if (!isNaN(idle) && n < idle) return 'Must be >= idle timeout'; diff --git a/src/cli/tui/screens/agent/types.ts b/src/cli/tui/screens/agent/types.ts index 0675e560..a22ff3c1 100644 --- a/src/cli/tui/screens/agent/types.ts +++ b/src/cli/tui/screens/agent/types.ts @@ -87,9 +87,9 @@ export interface AddAgentConfig { authorizerType?: RuntimeAuthorizerType; /** JWT config for CUSTOM_JWT authorizer */ jwtConfig?: JwtConfigOptions; - /** Idle session timeout in seconds (60-28800) */ + /** Idle session timeout in seconds (LIFECYCLE_TIMEOUT_MIN-LIFECYCLE_TIMEOUT_MAX) */ idleRuntimeSessionTimeout?: number; - /** Max instance lifetime in seconds (60-28800) */ + /** Max instance lifetime in seconds (LIFECYCLE_TIMEOUT_MIN-LIFECYCLE_TIMEOUT_MAX) */ maxLifetime?: number; /** Python version (only for Python agents) */ pythonVersion: PythonRuntime; diff --git a/src/cli/tui/screens/generate/GenerateWizardUI.tsx b/src/cli/tui/screens/generate/GenerateWizardUI.tsx index 82a8e665..c1f2c086 100644 --- a/src/cli/tui/screens/generate/GenerateWizardUI.tsx +++ b/src/cli/tui/screens/generate/GenerateWizardUI.tsx @@ -1,5 +1,5 @@ import type { ModelProvider, NetworkMode, RuntimeAuthorizerType } from '../../../../schema'; -import { DEFAULT_MODEL_IDS, ProjectNameSchema } from '../../../../schema'; +import { DEFAULT_MODEL_IDS, LIFECYCLE_TIMEOUT_MAX, LIFECYCLE_TIMEOUT_MIN, ProjectNameSchema } from '../../../../schema'; import { parseAndNormalizeHeaders, validateHeaderAllowlist } from '../../../commands/shared/header-utils'; import { validateSecurityGroupIds, validateSubnetIds } from '../../../commands/shared/vpc-utils'; import { computeDefaultCredentialEnvVarName } from '../../../primitives/credential-utils'; @@ -289,13 +289,13 @@ export function GenerateWizardUI({ {isIdleTimeoutStep && ( { if (!value) return true; const n = Number(value); - if (isNaN(n) || !Number.isInteger(n) || n < 60 || n > 28800) - return 'Must be an integer between 60 and 28800'; + if (isNaN(n) || !Number.isInteger(n) || n < LIFECYCLE_TIMEOUT_MIN || n > LIFECYCLE_TIMEOUT_MAX) + return `Must be an integer between ${LIFECYCLE_TIMEOUT_MIN} and ${LIFECYCLE_TIMEOUT_MAX}`; return true; }} onSubmit={value => { @@ -311,13 +311,13 @@ export function GenerateWizardUI({ {isMaxLifetimeStep && ( { if (!value) return true; const n = Number(value); - if (isNaN(n) || !Number.isInteger(n) || n < 60 || n > 28800) - return 'Must be an integer between 60 and 28800'; + if (isNaN(n) || !Number.isInteger(n) || n < LIFECYCLE_TIMEOUT_MIN || n > LIFECYCLE_TIMEOUT_MAX) + return `Must be an integer between ${LIFECYCLE_TIMEOUT_MIN} and ${LIFECYCLE_TIMEOUT_MAX}`; if (wizard.config.idleRuntimeSessionTimeout !== undefined && n < wizard.config.idleRuntimeSessionTimeout) { return 'Must be >= idle timeout'; } diff --git a/src/cli/tui/screens/generate/types.ts b/src/cli/tui/screens/generate/types.ts index c989c039..a4df6c68 100644 --- a/src/cli/tui/screens/generate/types.ts +++ b/src/cli/tui/screens/generate/types.ts @@ -54,9 +54,9 @@ export interface GenerateConfig { authorizerType?: RuntimeAuthorizerType; /** JWT config for CUSTOM_JWT authorizer */ jwtConfig?: JwtConfigOptions; - /** Idle session timeout in seconds (60-28800) */ + /** Idle session timeout in seconds (LIFECYCLE_TIMEOUT_MIN-LIFECYCLE_TIMEOUT_MAX) */ idleRuntimeSessionTimeout?: number; - /** Max instance lifetime in seconds (60-28800) */ + /** Max instance lifetime in seconds (LIFECYCLE_TIMEOUT_MIN-LIFECYCLE_TIMEOUT_MAX) */ maxLifetime?: number; } diff --git a/src/schema/schemas/agent-env.ts b/src/schema/schemas/agent-env.ts index f52e075c..19d73b36 100644 --- a/src/schema/schemas/agent-env.ts +++ b/src/schema/schemas/agent-env.ts @@ -141,16 +141,21 @@ export const RequestHeaderAllowlistSchema = z ) .max(MAX_HEADER_ALLOWLIST_SIZE, `Maximum ${MAX_HEADER_ALLOWLIST_SIZE} headers allowed`); +/** Minimum allowed value for lifecycle timeout fields (seconds). */ +export const LIFECYCLE_TIMEOUT_MIN = 60; +/** Maximum allowed value for lifecycle timeout fields (seconds). */ +export const LIFECYCLE_TIMEOUT_MAX = 28800; + /** * Lifecycle configuration for runtime sessions. * Controls idle timeout and max lifetime of runtime instances. */ export const LifecycleConfigurationSchema = z .object({ - /** Idle session timeout in seconds (60-28800). API default: 900s. */ - idleRuntimeSessionTimeout: z.number().int().min(60).max(28800).optional(), - /** Max instance lifetime in seconds (60-28800). API default: 28800s. */ - maxLifetime: z.number().int().min(60).max(28800).optional(), + /** Idle session timeout in seconds. API default: 900s. */ + idleRuntimeSessionTimeout: z.number().int().min(LIFECYCLE_TIMEOUT_MIN).max(LIFECYCLE_TIMEOUT_MAX).optional(), + /** Max instance lifetime in seconds. API default: 28800s. */ + maxLifetime: z.number().int().min(LIFECYCLE_TIMEOUT_MIN).max(LIFECYCLE_TIMEOUT_MAX).optional(), }) .superRefine((data, ctx) => { if (data.idleRuntimeSessionTimeout !== undefined && data.maxLifetime !== undefined) {