From c3f2b6ff40b1afbb8b169b2d665a7cc84a0aaf41 Mon Sep 17 00:00:00 2001 From: bgagent Date: Mon, 29 Jun 2026 19:35:35 -0400 Subject: [PATCH 01/18] fix(ecs): write task payload to S3, not inline overrides (#502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ECS compute strategy inlined the full orchestrator payload (incl. the large hydrated_context) into the AGENT_PAYLOAD container-override env var. ECS RunTask caps the TOTAL containerOverrides blob at 8192 bytes, so any real task was rejected before the container started: InvalidParameterException: Container Overrides length must be at most 8192 AgentCore is unaffected — it passes the payload in the InvokeAgentRuntime request body, which has no comparable limit. The bug only surfaces with a realistic hydrated payload, which is why the prior ECS smoke test (a small Rust cargo-check, #494) didn't catch it. Fix — stash the payload out-of-band and pass only a pointer: - New EcsPayloadBucket construct (mirrors TraceArtifactsBucket): BLOCK_ALL, enforceSSL, S3_MANAGED encryption, 1-day lifecycle TTL (payloads are ephemeral — read once at boot). Dedicated bucket so the ECS task role's S3 read is scoped to payloads only and can't touch attachments/traces. - ecs-strategy: when ECS_PAYLOAD_BUCKET is set, PutObject the payload to /payload.json and pass AGENT_PAYLOAD_S3_URI in the override; the boot command fetches+parses it via boto3. Inline AGENT_PAYLOAD remains as a fallback (small payloads / no bucket), so nothing regresses. deleteEcsPayload helper removes the object. - orchestrate-task finalize: best-effort deleteEcsPayload for ECS tasks once terminal (the container has long since read it); lifecycle rule is the crash backstop. - EcsAgentCluster: accept payloadBucket, inject ECS_PAYLOAD_BUCKET env, grant the task role READ ONLY (untrusted repo code must not write/delete payloads; the trusted orchestrator owns write+delete). Session-role-aware. - task-orchestrator: ecsPayloadBucket prop → grantPut + grantDelete to the orchestrator; @aws-sdk/client-s3 added to bundling externals. - agent.ts: updated the commented uncomment-to-enable ECS scaffolding to wire the payload bucket. Tests: new bucket construct (TTL/SSL/block-public/autoDelete); strategy S3-write + URI-pointer + inline fallback + deleteEcsPayload (incl. best-effort swallow + no-op without bucket); cluster read-grant + env var + read-only (no put/delete). Full build green. Closes #502 --- cdk/src/constructs/ecs-agent-cluster.ts | 28 +++- cdk/src/constructs/ecs-payload-bucket.ts | 117 +++++++++++++++ cdk/src/constructs/task-orchestrator.ts | 23 +++ cdk/src/handlers/orchestrate-task.ts | 9 ++ .../shared/strategies/ecs-strategy.ts | 104 ++++++++++++- cdk/src/stacks/agent.ts | 11 ++ cdk/test/constructs/ecs-agent-cluster.test.ts | 75 ++++++++++ .../constructs/ecs-payload-bucket.test.ts | 140 ++++++++++++++++++ .../shared/strategies/ecs-strategy.test.ts | 119 ++++++++++++++- 9 files changed, 617 insertions(+), 9 deletions(-) create mode 100644 cdk/src/constructs/ecs-payload-bucket.ts create mode 100644 cdk/test/constructs/ecs-payload-bucket.test.ts diff --git a/cdk/src/constructs/ecs-agent-cluster.ts b/cdk/src/constructs/ecs-agent-cluster.ts index 1ccbf7c4..5795c00a 100644 --- a/cdk/src/constructs/ecs-agent-cluster.ts +++ b/cdk/src/constructs/ecs-agent-cluster.ts @@ -24,6 +24,7 @@ import * as ecr_assets from 'aws-cdk-lib/aws-ecr-assets'; import * as ecs from 'aws-cdk-lib/aws-ecs'; import * as iam from 'aws-cdk-lib/aws-iam'; import * as logs from 'aws-cdk-lib/aws-logs'; +import * as s3 from 'aws-cdk-lib/aws-s3'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; import { NagSuppressions } from 'cdk-nag'; import { Construct } from 'constructs'; @@ -38,6 +39,18 @@ export interface EcsAgentClusterProps { readonly githubTokenSecret: secretsmanager.ISecret; readonly memoryId?: string; + /** + * S3 bucket holding per-task ECS payloads (#502). The orchestrator writes the + * payload (incl. the large hydrated_context, which can't fit in the 8 KB + * RunTask containerOverrides limit) here and passes only an + * `AGENT_PAYLOAD_S3_URI` pointer; the container fetches it on boot. The task + * role gets **read-only** on this bucket — the container runs untrusted repo + * code, so it must not be able to delete payloads (the trusted orchestrator + * owns write + delete). When omitted (isolated construct tests / deployments + * that still pass the payload inline), no grant or env var is added. + */ + readonly payloadBucket?: s3.IBucket; + /** * Per-task SessionRole (#209). When provided, tenant-data DynamoDB access * (task/events tables) is NOT granted to the Fargate task role; instead the @@ -132,6 +145,10 @@ export class EcsAgentCluster extends Construct { LOG_GROUP_NAME: logGroup.logGroupName, GITHUB_TOKEN_SECRET_ARN: props.githubTokenSecret.secretArn, ...(props.memoryId && { MEMORY_ID: props.memoryId }), + // #502: the payload bucket name so the orchestrator-issued + // AGENT_PAYLOAD_S3_URI can be fetched. (The orchestrator sets the URI + // per-task via container override; this is informational parity.) + ...(props.payloadBucket && { ECS_PAYLOAD_BUCKET: props.payloadBucket.bucketName }), // Per-session IAM scoping (#209): when a SessionRole is wired, the // agent assumes it for tenant-data access (see aws_session.py). ...(props.agentSessionRole && { @@ -160,6 +177,15 @@ export class EcsAgentCluster extends Construct { // agent assumes the SessionRole — stays on the task role). props.githubTokenSecret.grantRead(taskRole); + // #502: read-only on the ECS payload bucket so the container can fetch its + // payload (AGENT_PAYLOAD_S3_URI) at boot. READ only — the container runs + // untrusted repo code, so it must not be able to write or delete payloads + // (the trusted orchestrator owns write + delete). Stays on the task role + // (read once at startup, before the agent assumes any SessionRole). + if (props.payloadBucket) { + props.payloadBucket.grantRead(taskRole); + } + // Bedrock model invocation — scoped to explicit foundation-model and // cross-region inference-profile ARNs (parity with the AgentCore runtime // grants in agent.ts), replacing the prior Resource: '*' wildcard. @@ -201,7 +227,7 @@ export class EcsAgentCluster extends Construct { NagSuppressions.addResourceSuppressions(this.taskDefinition, [ { id: 'AwsSolutions-IAM5', - reason: 'DynamoDB index/* wildcards from CDK grantReadWriteData (UserConcurrencyTable, and task tables only when no SessionRole is wired); Secrets Manager wildcards from CDK grantRead; CloudWatch Logs wildcards from CDK grantWrite. Bedrock InvokeModel is scoped to explicit model/inference-profile ARNs (no wildcard resource).', + reason: 'DynamoDB index/* wildcards from CDK grantReadWriteData (UserConcurrencyTable, and task tables only when no SessionRole is wired); Secrets Manager wildcards from CDK grantRead; CloudWatch Logs wildcards from CDK grantWrite; S3 object/* wildcard from CDK grantRead on the ECS payload bucket (read-only, scoped to that bucket — #502). Bedrock InvokeModel is scoped to explicit model/inference-profile ARNs (no wildcard resource).', }, { id: 'AwsSolutions-ECS2', diff --git a/cdk/src/constructs/ecs-payload-bucket.ts b/cdk/src/constructs/ecs-payload-bucket.ts new file mode 100644 index 00000000..62a8c64c --- /dev/null +++ b/cdk/src/constructs/ecs-payload-bucket.ts @@ -0,0 +1,117 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Duration, RemovalPolicy } from 'aws-cdk-lib'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import { Construct } from 'constructs'; + +/** + * Lifecycle expiry for ECS task payloads. The payload is consumed once, at + * container boot, and the orchestrator deletes it promptly in the ``finalize`` + * step. This 1-day rule is only a crash backstop: if the orchestrator dies + * before finalize (rare — it runs under durable execution), the object is still + * reaped within a day instead of lingering. Payloads carry the hydrated prompt + * context, so a tight TTL also keeps the blast radius of an accidental + * permission leak small. + */ +export const ECS_PAYLOAD_TTL_DAYS = 1; + +/** + * Object-key prefix for ECS task payloads. Key layout: + * ``/payload.json``. Each task writes a single object under its own + * task-id prefix; the orchestrator deletes it on terminal. + */ +export const ECS_PAYLOAD_OBJECT_KEY_PREFIX = ''; + +/** + * Properties for the EcsPayloadBucket construct. + */ +export interface EcsPayloadBucketProps { + /** + * Removal policy for the bucket. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to auto-delete objects when the bucket is removed (so ``cdk + * destroy`` does not need a manual bucket-empty first). Mirrors + * ``TraceArtifactsBucket`` / ``AttachmentsBucket``. Deploys CDK's + * ``Custom::S3AutoDeleteObjects`` Lambda with delete permissions on this + * bucket — acceptable here because the contents are ephemeral throwaway + * payloads. + * @default true + */ + readonly autoDeleteObjects?: boolean; +} + +/** + * S3 bucket for ECS task payloads (#502). + * + * The ECS compute strategy cannot pass the orchestrator payload (repo URL, + * prompt, and the large ``hydrated_context``) inline: a Fargate ``RunTask`` + * caps the entire ``containerOverrides`` blob at 8192 bytes, and the hydrated + * context routinely exceeds that, so the call is rejected with + * ``InvalidParameterException``. (AgentCore is unaffected — it passes the + * payload in the ``InvokeAgentRuntime`` request body, which has no comparable + * limit.) Instead, the orchestrator writes the payload to + * ``s3:////payload.json`` and passes only a small + * ``AGENT_PAYLOAD_S3_URI`` pointer in the override; the container fetches and + * parses it on boot. + * + * Dedicated (not co-tenant with attachments/traces) so the boundary is + * structural: the ECS task role gets S3 **read** here and nowhere else, the + * attachments feature can never collide with payload keys, and the tight + * 1-day TTL is whole-bucket rather than a prefix-scoped rule grafted onto a + * shared bucket. + * + * Security / hygiene (parity with TraceArtifactsBucket): + * - ``blockPublicAccess: BLOCK_ALL`` + ``enforceSSL: true`` — no public read, + * TLS-only transport. + * - ``encryption: S3_MANAGED`` — server-side encryption at rest. + * - 1-day lifecycle expiry — payloads are ephemeral (read once at boot, + * deleted by the orchestrator at finalize); this is the crash backstop. + */ +export class EcsPayloadBucket extends Construct { + /** The underlying S3 bucket. */ + public readonly bucket: s3.Bucket; + + constructor(scope: Construct, id: string, props: EcsPayloadBucketProps = {}) { + super(scope, id); + + this.bucket = new s3.Bucket(this, 'Bucket', { + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + encryption: s3.BucketEncryption.S3_MANAGED, + enforceSSL: true, + lifecycleRules: [ + { + id: 'ecs-payload-ttl', + enabled: true, + expiration: Duration.days(ECS_PAYLOAD_TTL_DAYS), + // Reap incomplete multipart uploads after 1 day. Object expiration + // does not apply to in-flight MPUs (they are not objects yet), so a + // separate reaper keeps stale upload parts from lingering. + abortIncompleteMultipartUploadAfter: Duration.days(1), + }, + ], + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + autoDeleteObjects: props.autoDeleteObjects ?? true, + }); + } +} diff --git a/cdk/src/constructs/task-orchestrator.ts b/cdk/src/constructs/task-orchestrator.ts index b8b380ce..a638b369 100644 --- a/cdk/src/constructs/task-orchestrator.ts +++ b/cdk/src/constructs/task-orchestrator.ts @@ -159,6 +159,16 @@ export interface TaskOrchestratorProps { readonly executionRoleArn: string; }; + /** + * S3 bucket for per-task ECS payloads (#502). When provided (alongside + * ``ecsConfig``), the orchestrator writes the payload here and passes only an + * ``AGENT_PAYLOAD_S3_URI`` pointer in the RunTask override (the full payload + * exceeds the 8 KB containerOverrides limit), then deletes the object in the + * finalize step. The orchestrator gets write + delete; the ECS task role gets + * read-only (granted on the bucket by ``EcsAgentCluster``). + */ + readonly ecsPayloadBucket?: s3.IBucket; + /** * S3 bucket for task attachments. When provided, the orchestrator gets * ReadWrite grants for URL fetch/screen/upload during hydration. @@ -220,6 +230,7 @@ export class TaskOrchestrator extends Construct { '@aws-sdk/client-ecs', '@aws-sdk/client-lambda', '@aws-sdk/client-bedrock-runtime', + '@aws-sdk/client-s3', '@aws-sdk/client-secrets-manager', '@aws-sdk/lib-dynamodb', '@aws-sdk/util-dynamodb', @@ -262,6 +273,9 @@ export class TaskOrchestrator extends Construct { ECS_SECURITY_GROUP: props.ecsConfig.securityGroup, ECS_CONTAINER_NAME: props.ecsConfig.containerName, }), + // #502: bucket the orchestrator writes the ECS payload to (and deletes + // from at finalize); the ECS strategy reads this to build the S3 URI. + ...(props.ecsPayloadBucket && { ECS_PAYLOAD_BUCKET: props.ecsPayloadBucket.bucketName }), ...(props.attachmentsBucket && { ATTACHMENTS_BUCKET_NAME: props.attachmentsBucket.bucketName }), }, bundling: orchestratorBundling, @@ -280,6 +294,15 @@ export class TaskOrchestrator extends Construct { props.attachmentsBucket.grantReadWrite(this.fn); } + // #502: ECS payload bucket — the orchestrator writes the payload before + // RunTask and deletes it at finalize. Write + delete only (it never reads + // its own payload back; the ECS container is the reader, with its own + // read-only grant from EcsAgentCluster). + if (props.ecsPayloadBucket) { + props.ecsPayloadBucket.grantPut(this.fn); + props.ecsPayloadBucket.grantDelete(this.fn); + } + // Durable execution managed policy this.fn.role!.addManagedPolicy( iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicDurableExecutionRolePolicy'), diff --git a/cdk/src/handlers/orchestrate-task.ts b/cdk/src/handlers/orchestrate-task.ts index 2e7be6de..d547c990 100644 --- a/cdk/src/handlers/orchestrate-task.ts +++ b/cdk/src/handlers/orchestrate-task.ts @@ -36,6 +36,7 @@ import { type PollState, } from './shared/orchestrator'; import { runPreflightChecks } from './shared/preflight'; +import { deleteEcsPayload } from './shared/strategies/ecs-strategy'; import type { TaskRecord } from './shared/types'; import { workflowIsReadOnly, workflowRequiresRepo } from './shared/workflows'; @@ -297,6 +298,14 @@ const durableHandler: DurableExecutionHandler = asyn // Step 6: Finalize — update terminal status, emit events, release concurrency await context.step('finalize', async () => { await finalizeTask(taskId, finalPollState, task.user_id); + // #502: the task is terminal — the container has long since read its + // payload, so delete the ephemeral S3 payload object now. Best-effort + // (deleteEcsPayload swallows errors) and a no-op for AgentCore tasks / + // deployments without a payload bucket; the bucket's 1-day lifecycle rule + // is the backstop if this delete or the whole step never runs. + if (blueprintConfig.compute_type === 'ecs') { + await deleteEcsPayload(taskId); + } }); }; diff --git a/cdk/src/handlers/shared/strategies/ecs-strategy.ts b/cdk/src/handlers/shared/strategies/ecs-strategy.ts index 4ad3d0cb..df70e8a2 100644 --- a/cdk/src/handlers/shared/strategies/ecs-strategy.ts +++ b/cdk/src/handlers/shared/strategies/ecs-strategy.ts @@ -18,6 +18,7 @@ */ import { ECSClient, RunTaskCommand, DescribeTasksCommand, StopTaskCommand } from '@aws-sdk/client-ecs'; +import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; import type { ComputeStrategy, SessionHandle, SessionStatus } from '../compute-strategy'; import { logger } from '../logger'; import type { BlueprintConfig } from '../repo-config'; @@ -30,11 +31,60 @@ function getClient(): ECSClient { return sharedClient; } +let sharedS3Client: S3Client | undefined; +function getS3Client(): S3Client { + if (!sharedS3Client) { + sharedS3Client = new S3Client({}); + } + return sharedS3Client; +} + const ECS_CLUSTER_ARN = process.env.ECS_CLUSTER_ARN; const ECS_TASK_DEFINITION_ARN = process.env.ECS_TASK_DEFINITION_ARN; const ECS_SUBNETS = process.env.ECS_SUBNETS; const ECS_SECURITY_GROUP = process.env.ECS_SECURITY_GROUP; const ECS_CONTAINER_NAME = process.env.ECS_CONTAINER_NAME ?? 'AgentContainer'; +const ECS_PAYLOAD_BUCKET = process.env.ECS_PAYLOAD_BUCKET; + +/** + * Inline-payload size (bytes) above which we warn that RunTask will likely + * reject the call when no payload bucket is configured. ECS caps the TOTAL + * containerOverrides blob at 8192 bytes; the other env vars + command consume + * some of that, so 6 KB of payload is the practical danger line (#502). + */ +const INLINE_PAYLOAD_WARN_BYTES = 6144; + +/** + * S3 object key for a task's ECS payload. One object per task under its own + * task-id prefix; deleted by the orchestrator at finalize (see + * ``deleteEcsPayload``), with the bucket's 1-day lifecycle rule as a backstop. + */ +export function ecsPayloadKey(taskId: string): string { + return `${taskId}/payload.json`; +} + +/** + * Delete a task's ECS payload object. Best-effort: a failed delete must never + * fail the task — the bucket's 1-day lifecycle rule reaps it regardless. Called + * from the orchestrator's ``finalize`` step once the task is terminal. No-ops + * when the payload bucket isn't configured (AgentCore-only deployments). + */ +export async function deleteEcsPayload(taskId: string): Promise { + if (!ECS_PAYLOAD_BUCKET) return; + try { + await getS3Client().send(new DeleteObjectCommand({ + Bucket: ECS_PAYLOAD_BUCKET, + Key: ecsPayloadKey(taskId), + })); + logger.info('Deleted ECS payload object', { task_id: taskId }); + } catch (err) { + // Non-fatal — the lifecycle rule is the backstop. + logger.warn('Failed to delete ECS payload object (non-fatal)', { + task_id: taskId, + error: err instanceof Error ? err.message : String(err), + }); + } +} export class EcsComputeStrategy implements ComputeStrategy { readonly type = 'ecs'; @@ -63,6 +113,37 @@ export class EcsComputeStrategy implements ComputeStrategy { // This avoids the server entirely and runs the agent in batch mode. const payloadJson = JSON.stringify(payload); + // #502: the payload (esp. hydrated_context) routinely exceeds the 8192-byte + // cap that ECS RunTask enforces on the TOTAL containerOverrides blob, which + // rejected the call with InvalidParameterException. Write the payload to S3 + // and pass only a small pointer (AGENT_PAYLOAD_S3_URI); the container fetches + // it on boot. The inline AGENT_PAYLOAD remains as a fallback for small + // payloads / deployments without a payload bucket configured. + let payloadS3Uri: string | undefined; + if (ECS_PAYLOAD_BUCKET) { + const key = ecsPayloadKey(taskId); + await getS3Client().send(new PutObjectCommand({ + Bucket: ECS_PAYLOAD_BUCKET, + Key: key, + Body: payloadJson, + ContentType: 'application/json', + })); + payloadS3Uri = `s3://${ECS_PAYLOAD_BUCKET}/${key}`; + logger.info('Wrote ECS payload to S3', { + task_id: taskId, + bytes: payloadJson.length, + uri: payloadS3Uri, + }); + } else if (payloadJson.length > INLINE_PAYLOAD_WARN_BYTES) { + // No bucket configured AND the payload is large enough that the inline + // path will almost certainly blow the 8192-byte overrides cap. Surface a + // clear cause rather than a raw InvalidParameterException from RunTask. + logger.warn('ECS payload is large but ECS_PAYLOAD_BUCKET is not set — RunTask may reject it (see #502)', { + task_id: taskId, + bytes: payloadJson.length, + }); + } + const containerEnv = [ { name: 'TASK_ID', value: taskId }, { name: 'REPO_URL', value: String(payload.repo_url ?? '') }, @@ -73,9 +154,12 @@ export class EcsComputeStrategy implements ComputeStrategy { ...(blueprintConfig.model_id ? [{ name: 'ANTHROPIC_MODEL', value: blueprintConfig.model_id }] : []), ...(blueprintConfig.system_prompt_overrides ? [{ name: 'SYSTEM_PROMPT_OVERRIDES', value: blueprintConfig.system_prompt_overrides }] : []), { name: 'CLAUDE_CODE_USE_BEDROCK', value: '1' }, - // Full orchestrator payload as JSON — the Python wrapper reads this to - // call run_task() with all fields including hydrated_context. - { name: 'AGENT_PAYLOAD', value: payloadJson }, + // #502: prefer the S3 pointer; fall back to the inline payload when no + // bucket is configured (keeps small-payload / AgentCore-only deployments + // working with no behavior change). + ...(payloadS3Uri + ? [{ name: 'AGENT_PAYLOAD_S3_URI', value: payloadS3Uri }] + : [{ name: 'AGENT_PAYLOAD', value: payloadJson }]), ...(payload.github_token_secret_arn ? [{ name: 'GITHUB_TOKEN_SECRET_ARN', value: String(payload.github_token_secret_arn) }] : []), @@ -83,16 +167,22 @@ export class EcsComputeStrategy implements ComputeStrategy { ]; // Override the container command to run a Python one-liner that: - // 1. Reads the AGENT_PAYLOAD env var (full orchestrator payload JSON) - // 2. Calls entrypoint.run_task() directly with all fields - // 3. Exits with code 0 on success, 1 on failure + // 1. Loads the payload — from S3 (AGENT_PAYLOAD_S3_URI) when set, else the + // inline AGENT_PAYLOAD env var (fallback). + // 2. Calls entrypoint.run_task() directly with all fields. + // 3. Exits with code 0 on success, 1 on failure. // This bypasses the uvicorn server entirely — no HTTP, no OTEL noise. const bootCommand = [ 'python', '-c', 'import json, os, sys; ' + 'sys.path.insert(0, "/app/src"); ' + 'from entrypoint import run_task; ' - + 'p = json.loads(os.environ["AGENT_PAYLOAD"]); ' + + '_uri = os.environ.get("AGENT_PAYLOAD_S3_URI"); ' + + 'p = (' + + 'json.loads(__import__("boto3").client("s3").get_object(' + + 'Bucket=_uri.split("/",3)[2], Key=_uri.split("/",3)[3])["Body"].read()) ' + + 'if _uri else json.loads(os.environ["AGENT_PAYLOAD"])' + + '); ' + 'r = run_task(' + 'repo_url=p.get("repo_url",""), ' + 'task_description=p.get("prompt",""), ' diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index 192496d3..95bc3191 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -40,6 +40,7 @@ import { CedarWasmLayer } from '../constructs/cedar-wasm-layer'; import { ConcurrencyReconciler } from '../constructs/concurrency-reconciler'; import { DnsFirewall } from '../constructs/dns-firewall'; // import { EcsAgentCluster } from '../constructs/ecs-agent-cluster'; +// import { EcsPayloadBucket } from '../constructs/ecs-payload-bucket'; import { FanOutConsumer } from '../constructs/fanout-consumer'; import { GitHubScreenshotIntegration } from '../constructs/github-screenshot-integration'; import { JiraIntegration } from '../constructs/jira-integration'; @@ -594,6 +595,11 @@ export class AgentStack extends Stack { // platform: ecr_assets.Platform.LINUX_ARM64, // }); // + // // #502: ephemeral bucket for ECS task payloads — the orchestrator writes + // // the payload here (it exceeds the 8 KB RunTask containerOverrides limit) + // // and passes only an S3 URI pointer; the container fetches it on boot. + // const ecsPayloadBucket = new EcsPayloadBucket(this, 'EcsPayloadBucket'); + // // const ecsCluster = new EcsAgentCluster(this, 'EcsAgentCluster', { // vpc: agentVpc.vpc, // agentImageAsset, @@ -602,6 +608,8 @@ export class AgentStack extends Stack { // userConcurrencyTable: userConcurrencyTable.table, // githubTokenSecret, // memoryId: agentMemory.memory.memoryId, + // // #502: read-only grant so the container can fetch its payload. + // payloadBucket: ecsPayloadBucket.bucket, // // Per-session IAM scoping (#209): the ECS task role assumes the same // // SessionRole as the AgentCore runtime for tenant-data access. The // // construct admits the task role to the trust and injects @@ -631,6 +639,9 @@ export class AgentStack extends Stack { // taskRoleArn: ecsCluster.taskRoleArn, // executionRoleArn: ecsCluster.executionRoleArn, // }, + // // #502: pass the payload bucket so the orchestrator writes/deletes the + // // out-of-band payload and the ECS strategy builds the S3 URI pointer. + // ecsPayloadBucket: ecsPayloadBucket.bucket, }); // Now that the orchestrator exists, resolve the Lazy used by TaskApi at synth. diff --git a/cdk/test/constructs/ecs-agent-cluster.test.ts b/cdk/test/constructs/ecs-agent-cluster.test.ts index a6164a56..2d351644 100644 --- a/cdk/test/constructs/ecs-agent-cluster.test.ts +++ b/cdk/test/constructs/ecs-agent-cluster.test.ts @@ -310,3 +310,78 @@ describe('EcsAgentCluster construct', () => { }); }); }); + +describe('EcsAgentCluster payload bucket (#502)', () => { + function createWithPayloadBucket(): Template { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + const vpc = new ec2.Vpc(stack, 'Vpc', { maxAzs: 2 }); + const agentImageAsset = new ecr_assets.DockerImageAsset(stack, 'AgentImage', { + directory: path.join(__dirname, '..', '..', '..', 'agent'), + }); + const taskTable = new dynamodb.Table(stack, 'TaskTable', { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + }); + const taskEventsTable = new dynamodb.Table(stack, 'TaskEventsTable', { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'event_id', type: dynamodb.AttributeType.STRING }, + }); + const userConcurrencyTable = new dynamodb.Table(stack, 'UserConcurrencyTable', { + partitionKey: { name: 'user_id', type: dynamodb.AttributeType.STRING }, + }); + const githubTokenSecret = new secretsmanager.Secret(stack, 'GitHubTokenSecret'); + const payloadBucket = new s3.Bucket(stack, 'PayloadBucket'); + + new EcsAgentCluster(stack, 'EcsAgentCluster', { + vpc, + agentImageAsset, + taskTable, + taskEventsTable, + userConcurrencyTable, + githubTokenSecret, + payloadBucket, + }); + return Template.fromStack(stack); + } + + test('injects ECS_PAYLOAD_BUCKET into the container env', () => { + createWithPayloadBucket().hasResourceProperties('AWS::ECS::TaskDefinition', { + ContainerDefinitions: Match.arrayWith([ + Match.objectLike({ + Environment: Match.arrayWith([ + Match.objectLike({ Name: 'ECS_PAYLOAD_BUCKET', Value: Match.anyValue() }), + ]), + }), + ]), + }); + }); + + test('grants the task role READ on the payload bucket, never write/delete', () => { + const template = createWithPayloadBucket(); + const policies = template.findResources('AWS::IAM::Policy'); + const s3Actions = new Set(); + for (const policy of Object.values(policies)) { + for (const stmt of policy.Properties.PolicyDocument.Statement) { + const actions = Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action]; + for (const a of actions) { + if (typeof a === 'string' && a.startsWith('s3:')) s3Actions.add(a); + } + } + } + // Read actions present... + expect([...s3Actions].some(a => a === 's3:GetObject' || a === 's3:GetObject*')).toBe(true); + // ...and NO write/delete on the payload bucket from the task role. + expect(s3Actions.has('s3:PutObject')).toBe(false); + expect(s3Actions.has('s3:DeleteObject')).toBe(false); + expect([...s3Actions].some(a => a.startsWith('s3:Put') || a.startsWith('s3:Delete'))).toBe(false); + }); + + test('omits ECS_PAYLOAD_BUCKET when no payload bucket is provided', () => { + const { template } = createStack(); + const taskDefs = template.findResources('AWS::ECS::TaskDefinition'); + for (const def of Object.values(taskDefs)) { + const env = def.Properties.ContainerDefinitions[0].Environment ?? []; + expect(env.some((e: { Name: string }) => e.Name === 'ECS_PAYLOAD_BUCKET')).toBe(false); + } + }); +}); diff --git a/cdk/test/constructs/ecs-payload-bucket.test.ts b/cdk/test/constructs/ecs-payload-bucket.test.ts new file mode 100644 index 00000000..acf96bac --- /dev/null +++ b/cdk/test/constructs/ecs-payload-bucket.test.ts @@ -0,0 +1,140 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { App, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { Match, Template } from 'aws-cdk-lib/assertions'; +import { ECS_PAYLOAD_TTL_DAYS, EcsPayloadBucket } from '../../src/constructs/ecs-payload-bucket'; + +describe('EcsPayloadBucket', () => { + let template: Template; + + beforeEach(() => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new EcsPayloadBucket(stack, 'EcsPayloadBucket'); + template = Template.fromStack(stack); + }); + + test('creates an S3 bucket with all public access blocked', () => { + template.hasResourceProperties('AWS::S3::Bucket', { + PublicAccessBlockConfiguration: { + BlockPublicAcls: true, + BlockPublicPolicy: true, + IgnorePublicAcls: true, + RestrictPublicBuckets: true, + }, + }); + }); + + test('enables S3-managed server-side encryption', () => { + template.hasResourceProperties('AWS::S3::Bucket', { + BucketEncryption: { + ServerSideEncryptionConfiguration: [ + { ServerSideEncryptionByDefault: { SSEAlgorithm: 'AES256' } }, + ], + }, + }); + }); + + test('attaches a bucket policy enforcing TLS-only access', () => { + template.hasResourceProperties('AWS::S3::BucketPolicy', { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Effect: 'Deny', + Action: 's3:*', + Condition: { Bool: { 'aws:SecureTransport': 'false' } }, + }), + ]), + }, + }); + }); + + test('configures a 1-day expiration lifecycle rule (payloads are ephemeral)', () => { + template.hasResourceProperties('AWS::S3::Bucket', { + LifecycleConfiguration: { + Rules: Match.arrayWith([ + Match.objectLike({ + Id: 'ecs-payload-ttl', + Status: 'Enabled', + ExpirationInDays: ECS_PAYLOAD_TTL_DAYS, + }), + ]), + }, + }); + }); + + test('aborts incomplete multipart uploads within a day', () => { + template.hasResourceProperties('AWS::S3::Bucket', { + LifecycleConfiguration: { + Rules: Match.arrayWith([ + Match.objectLike({ + AbortIncompleteMultipartUpload: { DaysAfterInitiation: 1 }, + }), + ]), + }, + }); + }); + + test('sets DESTROY removal policy by default', () => { + template.hasResource('AWS::S3::Bucket', { + DeletionPolicy: 'Delete', + UpdateReplacePolicy: 'Delete', + }); + }); + + test('enables autoDeleteObjects by default (matches TraceArtifactsBucket / AttachmentsBucket)', () => { + template.hasResourceProperties('Custom::S3AutoDeleteObjects', { + BucketName: Match.anyValue(), + }); + }); + + test('exposes a bucket handle via the `bucket` property', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + const payload = new EcsPayloadBucket(stack, 'EcsPayloadBucket'); + expect(payload.bucket).toBeDefined(); + expect(payload.bucket.bucketName).toBeDefined(); + }); +}); + +describe('EcsPayloadBucket with custom props', () => { + test('accepts custom removal policy and disables autoDelete', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new EcsPayloadBucket(stack, 'EcsPayloadBucket', { + removalPolicy: RemovalPolicy.RETAIN, + autoDeleteObjects: false, + }); + const template = Template.fromStack(stack); + + template.hasResource('AWS::S3::Bucket', { + DeletionPolicy: 'Retain', + UpdateReplacePolicy: 'Retain', + }); + const customResources = template.findResources('Custom::S3AutoDeleteObjects'); + expect(Object.keys(customResources)).toHaveLength(0); + }); +}); + +describe('EcsPayloadBucket exported constants', () => { + test('TTL is 1 day (ephemeral payload, deleted promptly at finalize)', () => { + expect(ECS_PAYLOAD_TTL_DAYS).toBe(1); + }); +}); diff --git a/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts b/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts index 44748370..bee9c41a 100644 --- a/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts +++ b/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts @@ -36,6 +36,13 @@ jest.mock('@aws-sdk/client-ecs', () => ({ StopTaskCommand: jest.fn((input: unknown) => ({ _type: 'StopTask', input })), })); +const mockS3Send = jest.fn(); +jest.mock('@aws-sdk/client-s3', () => ({ + S3Client: jest.fn(() => ({ send: mockS3Send })), + PutObjectCommand: jest.fn((input: unknown) => ({ _type: 'PutObject', input })), + DeleteObjectCommand: jest.fn((input: unknown) => ({ _type: 'DeleteObject', input })), +})); + import { EcsComputeStrategy } from '../../../../src/handlers/shared/strategies/ecs-strategy'; beforeEach(() => { @@ -88,12 +95,15 @@ describe('EcsComputeStrategy', () => { { name: 'CLAUDE_CODE_USE_BEDROCK', value: '1' }, ])); - // AGENT_PAYLOAD contains the full orchestrator payload for direct run_task() invocation + // No ECS_PAYLOAD_BUCKET in this module's env → inline fallback (#502): the + // full payload rides in AGENT_PAYLOAD and nothing is written to S3. const agentPayload = envVars.find((e: { name: string }) => e.name === 'AGENT_PAYLOAD'); expect(agentPayload).toBeDefined(); const parsed = JSON.parse(agentPayload.value); expect(parsed.repo_url).toBe('org/repo'); expect(parsed.prompt).toBe('Fix the bug'); + expect(envVars.find((e: { name: string }) => e.name === 'AGENT_PAYLOAD_S3_URI')).toBeUndefined(); + expect(mockS3Send).not.toHaveBeenCalled(); // Container command override — runs Python directly instead of uvicorn expect(override.command).toBeDefined(); @@ -336,3 +346,110 @@ describe('EcsComputeStrategy', () => { }); }); }); + +// #502: the S3-pointer path requires ECS_PAYLOAD_BUCKET to be set BEFORE the +// module is imported (it's a module-level constant). Re-import under +// jest.isolateModules with the env var set so these tests don't perturb the +// inline-fallback tests above. +describe('EcsComputeStrategy with ECS_PAYLOAD_BUCKET (S3-pointer path, #502)', () => { + const PAYLOAD_BUCKET = 'test-ecs-payload-bucket'; + + function loadStrategyWithBucket(): { + EcsComputeStrategy: typeof import('../../../../src/handlers/shared/strategies/ecs-strategy').EcsComputeStrategy; + deleteEcsPayload: typeof import('../../../../src/handlers/shared/strategies/ecs-strategy').deleteEcsPayload; + ecsPayloadKey: typeof import('../../../../src/handlers/shared/strategies/ecs-strategy').ecsPayloadKey; + } { + let mod!: ReturnType; + jest.isolateModules(() => { + process.env.ECS_PAYLOAD_BUCKET = PAYLOAD_BUCKET; + process.env.ECS_CLUSTER_ARN = CLUSTER_ARN; + process.env.ECS_TASK_DEFINITION_ARN = TASK_DEF_ARN; + process.env.ECS_SUBNETS = 'subnet-aaa,subnet-bbb'; + process.env.ECS_SECURITY_GROUP = 'sg-12345'; + process.env.ECS_CONTAINER_NAME = 'AgentContainer'; + // eslint-disable-next-line @typescript-eslint/no-require-imports + mod = require('../../../../src/handlers/shared/strategies/ecs-strategy'); + }); + return mod; + } + + afterEach(() => { + delete process.env.ECS_PAYLOAD_BUCKET; + }); + + test('writes payload to S3 and passes AGENT_PAYLOAD_S3_URI, not the inline blob', async () => { + mockS3Send.mockResolvedValueOnce({}); + mockSend.mockResolvedValueOnce({ tasks: [{ taskArn: TASK_ARN }] }); + + const { EcsComputeStrategy: Strategy } = loadStrategyWithBucket(); + const strategy = new Strategy(); + await strategy.startSession({ + taskId: 'TASK001', + userId: 'cognito-test', + payload: { repo_url: 'org/repo', prompt: 'Fix the bug', hydrated_context: { big: 'x'.repeat(10000) } }, + blueprintConfig: { compute_type: 'ecs', runtime_arn: '' }, + }); + + // PutObject to the payload bucket at /payload.json + expect(mockS3Send).toHaveBeenCalledTimes(1); + const put = mockS3Send.mock.calls[0][0]; + expect(put._type).toBe('PutObject'); + expect(put.input.Bucket).toBe(PAYLOAD_BUCKET); + expect(put.input.Key).toBe('TASK001/payload.json'); + expect(JSON.parse(put.input.Body).repo_url).toBe('org/repo'); + + // Override carries the URI pointer, NOT the inline payload + const envVars = mockSend.mock.calls[0][0].input.overrides.containerOverrides[0].environment; + const uri = envVars.find((e: { name: string }) => e.name === 'AGENT_PAYLOAD_S3_URI'); + expect(uri.value).toBe(`s3://${PAYLOAD_BUCKET}/TASK001/payload.json`); + expect(envVars.find((e: { name: string }) => e.name === 'AGENT_PAYLOAD')).toBeUndefined(); + }); + + test('boot command loads payload from S3 when the URI is set, else inline', async () => { + mockS3Send.mockResolvedValueOnce({}); + mockSend.mockResolvedValueOnce({ tasks: [{ taskArn: TASK_ARN }] }); + + const { EcsComputeStrategy: Strategy } = loadStrategyWithBucket(); + await new Strategy().startSession({ + taskId: 'TASK001', + userId: 'cognito-test', + payload: { repo_url: 'org/repo' }, + blueprintConfig: { compute_type: 'ecs', runtime_arn: '' }, + }); + + const cmd = mockSend.mock.calls[0][0].input.overrides.containerOverrides[0].command; + const src = cmd[2]; + // Reads the URI, fetches via boto3 S3 when set, falls back to inline env. + expect(src).toContain('AGENT_PAYLOAD_S3_URI'); + expect(src).toContain('get_object'); + expect(src).toContain('AGENT_PAYLOAD'); + }); + + test('deleteEcsPayload deletes the task payload object', async () => { + mockS3Send.mockResolvedValueOnce({}); + const { deleteEcsPayload, ecsPayloadKey } = loadStrategyWithBucket(); + await deleteEcsPayload('TASK001'); + expect(mockS3Send).toHaveBeenCalledTimes(1); + const del = mockS3Send.mock.calls[0][0]; + expect(del._type).toBe('DeleteObject'); + expect(del.input.Bucket).toBe(PAYLOAD_BUCKET); + expect(del.input.Key).toBe(ecsPayloadKey('TASK001')); + expect(ecsPayloadKey('TASK001')).toBe('TASK001/payload.json'); + }); + + test('deleteEcsPayload swallows S3 errors (best-effort — lifecycle is the backstop)', async () => { + mockS3Send.mockRejectedValueOnce(new Error('AccessDenied')); + const { deleteEcsPayload } = loadStrategyWithBucket(); + await expect(deleteEcsPayload('TASK001')).resolves.toBeUndefined(); + }); +}); + +describe('deleteEcsPayload without ECS_PAYLOAD_BUCKET', () => { + test('no-ops when no payload bucket is configured', async () => { + // The top-of-file import has no ECS_PAYLOAD_BUCKET set. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { deleteEcsPayload } = require('../../../../src/handlers/shared/strategies/ecs-strategy'); + await expect(deleteEcsPayload('TASK001')).resolves.toBeUndefined(); + expect(mockS3Send).not.toHaveBeenCalled(); + }); +}); From 6e0db968e889849ef4dd25bad87b5ed90e1f1ccf Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Mon, 29 Jun 2026 13:06:22 -0400 Subject: [PATCH 02/18] fix: enable corepack in the agent image so yarn/pnpm resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the exit-127 inert gate: the agent Dockerfile installs node/npm/ mise/uv but NOT yarn, so any repo whose build runs 'yarn install' (incl. ABCA) hit 'yarn: command not found' and the agent hand-built a ~/bin/yarn shim every run. corepack (ships with Node 24) installs the yarn/pnpm shims at image-build time. Systemic — fixes every yarn repo, no per-run shim. Needs redeploy to take effect. (cherry picked from commit 2fd5fca0f702b11c4a7b98f8030b76889650bef6) (cherry picked from commit d5e0e6407c0aa30b1a14928a9fdeda1e00f50797) --- agent/Dockerfile | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/agent/Dockerfile b/agent/Dockerfile index 82c5f5e1..bf6723ad 100644 --- a/agent/Dockerfile +++ b/agent/Dockerfile @@ -44,6 +44,15 @@ RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* +# Enable Corepack so ``yarn`` / ``pnpm`` resolve out of the box. Many target +# repos (including ABCA itself) drive installs with ``yarn install`` via their +# build command; without this, ``yarn`` is "command not found" (exit 127) and +# the build-verification GATE runs inert — a green build is reported for a repo +# we never actually built (live-caught 2026-06-29 dogfooding ABCA-on-ABCA: the +# agent had to hand-build a ``~/bin/yarn`` shim every run). Corepack ships with +# Node 24; ``enable`` installs the yarn/pnpm shims onto PATH. +RUN corepack enable && corepack prepare yarn@stable --activate 2>/dev/null || corepack enable + # Install Claude Code CLI (the Python SDK requires this binary) # Then update known vulnerable transitive packages where fixed versions exist. # Pinned 2.1.191 to match the CLI bundled by claude-agent-sdk 0.2.110 (see From bef9b86eadd0822fd31e0284cee7ac7f9ec2121c Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Mon, 29 Jun 2026 14:36:06 -0400 Subject: [PATCH 03/18] wire ECS/Fargate substrate (context-gated) at 32GB/8vCPU for heavy builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgentCore's fixed microVM envelope OOM-kills CI-parity builds (live-caught dogfooding ABCA-on-ABCA: the ~2800-test `mise run build` killed the VM at the build-gate step). AgentCore memory isn't tunable, so route repos that set `compute_type: 'ecs'` to a Fargate task instead. - agent.ts: gate EcsAgentCluster + the orchestrator ecsConfig on the `compute_type` deploy context (default 'agentcore'). ECS resources only synthesize under `--context compute_type=ecs`, so default synth (and the bootstrap-coverage test) stays agentcore-only. Mirrors upstream's context-gating of the ECS construct. - ecs-agent-cluster.ts: size the Fargate task at 8 vCPU / 32 GB (was 2/4) — a valid ARM64 combo with headroom for the jest worker fleet + tsc that AgentCore's envelope can't give. Comment documents the live OOM. - test: assert the new 8192/32768 sizing. Verified: default synth has 0 ECS resources; `--context compute_type=ecs` synth emits AWS::ECS::Cluster + TaskDefinition. Full `mise run build` green. (cherry picked from commit 8cb7cfadfbc90301b321264b93fe66dfa1daad8d) (cherry picked from commit 632a36b5f60fedce3d8512c89107a227b7f3825a) --- cdk/src/constructs/ecs-agent-cluster.ts | 12 +- cdk/src/stacks/agent.ts | 135 +++++++++++------- cdk/test/constructs/ecs-agent-cluster.test.ts | 6 +- 3 files changed, 98 insertions(+), 55 deletions(-) diff --git a/cdk/src/constructs/ecs-agent-cluster.ts b/cdk/src/constructs/ecs-agent-cluster.ts index a73a8435..437bbae3 100644 --- a/cdk/src/constructs/ecs-agent-cluster.ts +++ b/cdk/src/constructs/ecs-agent-cluster.ts @@ -109,10 +109,16 @@ export class EcsAgentCluster extends Construct { // CDK creates this automatically via taskDefinition, but we need to // grant additional permissions to the task role. - // Fargate task definition + // Fargate task definition. Sized for heavy CI-parity builds (e.g. ABCA's + // own ~2800-test `mise run build` + cdk synth). The previous 4 GB / 2 vCPU + // OOM-killed even the AgentCore microVM on that build (live-caught + // 2026-06-29 dogfooding ABCA-on-ABCA); 32 GB / 8 vCPU is a valid Fargate + // ARM64 combination and gives the jest worker fleet + tsc the headroom + // AgentCore's fixed envelope can't. (Fargate ARM64 allows up to 16 vCPU / + // 120 GB if a future build needs more.) this.taskDefinition = new ecs.FargateTaskDefinition(this, 'TaskDef', { - cpu: 2048, - memoryLimitMiB: 4096, + cpu: 8192, + memoryLimitMiB: 32768, runtimePlatform: { cpuArchitecture: ecs.CpuArchitecture.ARM64, operatingSystemFamily: ecs.OperatingSystemFamily.LINUX, diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index 7746a509..69f2bdd0 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -22,8 +22,7 @@ import * as bedrock from '@aws-cdk/aws-bedrock-alpha'; import { ArnFormat, AspectPriority, Aspects, Stack, StackProps, RemovalPolicy, CfnOutput, CfnResource, Duration, Fn, Lazy } from 'aws-cdk-lib'; import * as agentcore from 'aws-cdk-lib/aws-bedrockagentcore'; import * as ec2 from 'aws-cdk-lib/aws-ec2'; -// ecr_assets import is only needed when the ECS block below is uncommented -// import * as ecr_assets from 'aws-cdk-lib/aws-ecr-assets'; +import * as ecr_assets from 'aws-cdk-lib/aws-ecr-assets'; import * as iam from 'aws-cdk-lib/aws-iam'; import * as logs from 'aws-cdk-lib/aws-logs'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; @@ -40,8 +39,8 @@ import { Blueprint } from '../constructs/blueprint'; import { CedarWasmLayer } from '../constructs/cedar-wasm-layer'; import { ConcurrencyReconciler } from '../constructs/concurrency-reconciler'; import { DnsFirewall } from '../constructs/dns-firewall'; -// import { EcsAgentCluster } from '../constructs/ecs-agent-cluster'; -// import { EcsPayloadBucket } from '../constructs/ecs-payload-bucket'; +import { EcsAgentCluster } from '../constructs/ecs-agent-cluster'; +import { EcsPayloadBucket } from '../constructs/ecs-payload-bucket'; import { FanOutConsumer } from '../constructs/fanout-consumer'; import { GitHubScreenshotIntegration } from '../constructs/github-screenshot-integration'; import { JiraIntegration } from '../constructs/jira-integration'; @@ -577,38 +576,73 @@ export class AgentStack extends Stack { description: 'Name of the S3 bucket storing --trace trajectory artifacts (design §10.1)', }); - // --- ECS Fargate compute backend (optional) --- - // To enable ECS as an alternative compute backend, uncomment the block below - // and the EcsAgentCluster import at the top of this file. Repos can then use - // compute_type: 'ecs' in their blueprint config to route tasks to ECS Fargate. - // - // const agentImageAsset = new ecr_assets.DockerImageAsset(this, 'AgentImage', { - // directory: repoRoot, - // file: 'agent/Dockerfile', - // platform: ecr_assets.Platform.LINUX_ARM64, - // }); - // - // // #502: ephemeral bucket for ECS task payloads — the orchestrator writes - // // the payload here (it exceeds the 8 KB RunTask containerOverrides limit) - // // and passes only an S3 URI pointer; the container fetches it on boot. - // const ecsPayloadBucket = new EcsPayloadBucket(this, 'EcsPayloadBucket'); - // - // const ecsCluster = new EcsAgentCluster(this, 'EcsAgentCluster', { - // vpc: agentVpc.vpc, - // agentImageAsset, - // taskTable: taskTable.table, - // taskEventsTable: taskEventsTable.table, - // userConcurrencyTable: userConcurrencyTable.table, - // githubTokenSecret, - // memoryId: agentMemory.memory.memoryId, - // // #502: read-only grant so the container can fetch its payload. - // payloadBucket: ecsPayloadBucket.bucket, - // // Per-session IAM scoping (#209): the ECS task role assumes the same - // // SessionRole as the AgentCore runtime for tenant-data access. The - // // construct admits the task role to the trust and injects - // // AGENT_SESSION_ROLE_ARN into the container. - // agentSessionRole, - // }); + // --- ECS Fargate compute backend (CONTEXT-GATED) --- + // K12 (2026-06-29): AgentCore's fixed microVM envelope OOM-kills heavy + // CI-parity builds (ABCA's own ~2800-test `mise run build`). ECS Fargate + // gives a tunable 64 GB / 16 vCPU task (see EcsAgentCluster) for repos that + // set ``compute_type: 'ecs'``. GATED on the ``compute_type`` deploy context + // (default 'agentcore') — ECS resources only synthesize when you deploy with + // ``--context compute_type=ecs``, so the default synth (and the + // bootstrap-coverage test that synths with default context) stays + // agentcore-only. Mirrors upstream #164 (gate ECS construct on context). + const computeType = this.node.tryGetContext('compute_type') ?? 'agentcore'; + // #502: ephemeral bucket for ECS task payloads — the orchestrator writes the + // payload here (it exceeds the 8 KB RunTask containerOverrides limit) and + // passes only an S3 URI pointer; the container fetches it on boot, the + // orchestrator deletes it at finalize. Only synthesized under the ecs gate. + const ecsPayloadBucket = computeType === 'ecs' + ? new EcsPayloadBucket(this, 'EcsPayloadBucket') + : undefined; + if (ecsPayloadBucket) { + NagSuppressions.addResourceSuppressions(ecsPayloadBucket.bucket, [ + { + id: 'AwsSolutions-S1', + reason: 'Ephemeral per-task payloads (#502) with a 1-day TTL; writes confined to the orchestrator IAM role by grantPut, reads to the ECS task role by grantRead, both scoped to this bucket. Object deleted at finalize. Object-level audit intentionally omitted — CloudTrail data events / a log bucket are not justified for transient boot payloads.', + }, + ]); + } + const ecsCluster = computeType === 'ecs' + ? new EcsAgentCluster(this, 'EcsAgentCluster', { + vpc: agentVpc.vpc, + agentImageAsset: new ecr_assets.DockerImageAsset(this, 'AgentImage', { + directory: repoRoot, + file: 'agent/Dockerfile', + platform: ecr_assets.Platform.LINUX_ARM64, + }), + taskTable: taskTable.table, + taskEventsTable: taskEventsTable.table, + userConcurrencyTable: userConcurrencyTable.table, + githubTokenSecret, + memoryId: agentMemory.memory.memoryId, + // F-2: grant the ECS task role read+write on AgentCore Memory so the + // agent's cross-task learning writes succeed on ECS (parity with the + // runtime's agentMemory.grantReadWrite below). + agentMemory, + // #502: read-only grant so the container can fetch its payload from S3. + payloadBucket: ecsPayloadBucket!.bucket, + // #299 ECS-parity: the same bucket the runtime uses for ARTIFACTS_BUCKET_NAME — + // coding/decompose-v1 delivers its plan artifact here (read+write grant in + // the construct). Without this, an ecs-repo :decompose fails at delivery. + artifactsBucket: traceArtifactsBucket.bucket, + // Per-session IAM scoping (#209): the ECS task role assumes the same + // SessionRole as the AgentCore runtime for tenant-data access. The + // construct admits the task role to the trust and injects + // AGENT_SESSION_ROLE_ARN into the container. + agentSessionRole, + }) + : undefined; + + // Advertise which compute substrate this deploy actually provisioned, so the + // CLI can refuse to onboard a repo as ``compute_type: ecs`` when the ECS gate + // wasn't on (``--context compute_type=ecs``) — otherwise that mismatch only + // surfaces per-task as "ECS compute strategy requires ECS_CLUSTER_ARN…" at + // runtime. ``ecs`` implies the AgentCore runtime is ALSO available (the ECS + // gate is additive), so an agentcore repo works on either substrate. + new CfnOutput(this, 'ComputeSubstrate', { + value: ecsCluster ? 'ecs' : 'agentcore', + description: 'Compute substrate provisioned by this deploy: "agentcore" (default) or "ecs" ' + + '(deployed with --context compute_type=ecs; adds the Fargate substrate alongside AgentCore).', + }); // --- Task Orchestrator (durable Lambda function) --- const orchestrator = new TaskOrchestrator(this, 'TaskOrchestrator', { @@ -622,19 +656,22 @@ export class AgentStack extends Stack { guardrailId: inputGuardrail.guardrailId, guardrailVersion: inputGuardrail.guardrailVersion, attachmentsBucket: attachmentsBucket.bucket, - // To wire ECS, uncomment the ecsCluster block above and add: - // ecsConfig: { - // clusterArn: ecsCluster.cluster.clusterArn, - // taskDefinitionArn: ecsCluster.taskDefinition.taskDefinitionArn, - // subnets: agentVpc.vpc.selectSubnets({ subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }).subnetIds.join(','), - // securityGroup: ecsCluster.securityGroup.securityGroupId, - // containerName: ecsCluster.containerName, - // taskRoleArn: ecsCluster.taskRoleArn, - // executionRoleArn: ecsCluster.executionRoleArn, - // }, - // // #502: pass the payload bucket so the orchestrator writes/deletes the - // // out-of-band payload and the ECS strategy builds the S3 URI pointer. - // ecsPayloadBucket: ecsPayloadBucket.bucket, + // K12: route ``compute_type: 'ecs'`` repos to the Fargate cluster above — + // only when the cluster was synthesized (deploy --context compute_type=ecs). + ...(ecsCluster && { + ecsConfig: { + clusterArn: ecsCluster.cluster.clusterArn, + taskDefinitionArn: ecsCluster.taskDefinition.taskDefinitionArn, + subnets: agentVpc.vpc.selectSubnets({ subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }).subnetIds.join(','), + securityGroup: ecsCluster.securityGroup.securityGroupId, + containerName: ecsCluster.containerName, + taskRoleArn: ecsCluster.taskRoleArn, + executionRoleArn: ecsCluster.executionRoleArn, + }, + }), + // #502: pass the payload bucket so the orchestrator writes/deletes the + // out-of-band payload and the ECS strategy builds the S3 URI pointer. + ...(ecsPayloadBucket && { ecsPayloadBucket: ecsPayloadBucket.bucket }), }); // Now that the orchestrator exists, resolve the Lazy used by TaskApi at synth. diff --git a/cdk/test/constructs/ecs-agent-cluster.test.ts b/cdk/test/constructs/ecs-agent-cluster.test.ts index fe0ec378..979a6c11 100644 --- a/cdk/test/constructs/ecs-agent-cluster.test.ts +++ b/cdk/test/constructs/ecs-agent-cluster.test.ts @@ -88,10 +88,10 @@ describe('EcsAgentCluster construct', () => { }); }); - test('creates a Fargate task definition with 2 vCPU and 4 GB', () => { + test('creates a Fargate task definition with 8 vCPU and 32 GB (K12: sized for heavy CI-parity builds)', () => { baseTemplate.hasResourceProperties('AWS::ECS::TaskDefinition', { - Cpu: '2048', - Memory: '4096', + Cpu: '8192', + Memory: '32768', RequiresCompatibilities: ['FARGATE'], RuntimePlatform: { CpuArchitecture: 'ARM64', From c6a2f283eb69a5db6b1767ccc1c8c3b865141ab5 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Mon, 29 Jun 2026 16:27:58 -0400 Subject: [PATCH 04/18] bump ECS Fargate to 64GB/16vCPU + BUILD_VERIFY_TIMEOUT_S=3600 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-fired the full `mise run build` on the 32GB ECS task (fork dogfood): install OK, build ran ~50 min then OOM-killed at the cap (ECS stoppedReason "OutOfMemoryError: container killed due to memory usage", exit 137; peak working set ~31.6 GB). Root cause: the monorepo build fans out 4 heavy jobs in PARALLEL (agent:quality ‖ cdk:build ‖ cli:build ‖ docs:build), each with its own worker fleet (jest, pytest, esbuild Lambda bundling) — 32 GB had no headroom for that concurrent peak, and the ~50-min wall-clock also blew the 1800s build-gate cap. Fix (keep the full build as the gate, give it room): - ecs-agent-cluster.ts: Fargate 8vCPU/32GB → 16vCPU/64GB (valid ARM64 combo; 16 vCPU admits 32–120 GB). 2× memory headroom for the parallel storm + more cores to shorten wall-clock. Comment records the full sizing history. - ecs-agent-cluster.ts: inject BUILD_VERIFY_TIMEOUT_S=3600 into the container env so a slow-but-healthy CI-parity build isn't mis-flagged as a timeout (post_hooks.py reads it; default 1800 stays for AgentCore repos). - tests: assert 16384/65536 + the new env var. Verified: default synth ECS-free; --context compute_type=ecs synth emits Cpu 16384 / Memory 65536 / BUILD_VERIFY_TIMEOUT_S 3600. Full build green. (cherry picked from commit 11fb3a5e0309ce0c7b5225af3e34e1b39138ce55) (cherry picked from commit cd7b8eb262e9e1e13dc539a3c280ffa59ba45914) --- cdk/src/constructs/ecs-agent-cluster.ts | 29 ++++++++++++++----- cdk/test/constructs/ecs-agent-cluster.test.ts | 9 ++++-- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/cdk/src/constructs/ecs-agent-cluster.ts b/cdk/src/constructs/ecs-agent-cluster.ts index 437bbae3..eb2c51f8 100644 --- a/cdk/src/constructs/ecs-agent-cluster.ts +++ b/cdk/src/constructs/ecs-agent-cluster.ts @@ -110,15 +110,22 @@ export class EcsAgentCluster extends Construct { // grant additional permissions to the task role. // Fargate task definition. Sized for heavy CI-parity builds (e.g. ABCA's - // own ~2800-test `mise run build` + cdk synth). The previous 4 GB / 2 vCPU - // OOM-killed even the AgentCore microVM on that build (live-caught - // 2026-06-29 dogfooding ABCA-on-ABCA); 32 GB / 8 vCPU is a valid Fargate - // ARM64 combination and gives the jest worker fleet + tsc the headroom - // AgentCore's fixed envelope can't. (Fargate ARM64 allows up to 16 vCPU / - // 120 GB if a future build needs more.) + // own ~2800-test `mise run build` + cdk synth). Sizing history (all + // live-caught dogfooding ABCA-on-ABCA, 2026-06-29): + // - 4 GB / 2 vCPU → OOM-killed even the AgentCore microVM. + // - 32 GB / 8 vCPU → ran ~50 min then OOM-killed (exit 137) at the cap; + // peak working set ~31.6 GB when the root build fans out 4 heavy jobs + // in PARALLEL (agent:quality ‖ cdk:build ‖ cli:build ‖ docs:build), + // each spawning its own worker fleet (jest maxWorkers, pytest, esbuild + // Lambda bundling). 32 GB had no headroom for that concurrent peak. + // - 64 GB / 16 vCPU (current) → 2× the memory headroom for the parallel + // storm, and 16 vCPU shortens wall-clock (paired with + // BUILD_VERIFY_TIMEOUT_S=3600 below so the ~longer-than-30-min build + // isn't mis-reported as a timeout). Valid Fargate ARM64 combo (16 vCPU + // admits 32–120 GB in 8 GB steps). this.taskDefinition = new ecs.FargateTaskDefinition(this, 'TaskDef', { - cpu: 8192, - memoryLimitMiB: 32768, + cpu: 16384, + memoryLimitMiB: 65536, runtimePlatform: { cpuArchitecture: ecs.CpuArchitecture.ARM64, operatingSystemFamily: ecs.OperatingSystemFamily.LINUX, @@ -139,6 +146,12 @@ export class EcsAgentCluster extends Construct { USER_CONCURRENCY_TABLE_NAME: props.userConcurrencyTable.tableName, LOG_GROUP_NAME: logGroup.logGroupName, GITHUB_TOKEN_SECRET_ARN: props.githubTokenSecret.secretArn, + // Heavy CI-parity builds on this big-box substrate legitimately run + // longer than the 1800s default (ABCA's own `mise run build` is + // ~50 min cold). Raise the post-agent build-verify cap so a slow-but- + // healthy build isn't mis-reported as a timeout (see post_hooks.py + // BUILD_VERIFY_TIMEOUT_S). ECS-only: AgentCore repos keep the default. + BUILD_VERIFY_TIMEOUT_S: '3600', ...(props.memoryId && { MEMORY_ID: props.memoryId }), // #502: the payload bucket name so the orchestrator-issued // AGENT_PAYLOAD_S3_URI can be fetched. (The orchestrator sets the URI diff --git a/cdk/test/constructs/ecs-agent-cluster.test.ts b/cdk/test/constructs/ecs-agent-cluster.test.ts index 979a6c11..ce5716e7 100644 --- a/cdk/test/constructs/ecs-agent-cluster.test.ts +++ b/cdk/test/constructs/ecs-agent-cluster.test.ts @@ -88,10 +88,10 @@ describe('EcsAgentCluster construct', () => { }); }); - test('creates a Fargate task definition with 8 vCPU and 32 GB (K12: sized for heavy CI-parity builds)', () => { + test('creates a Fargate task definition with 16 vCPU and 64 GB (K14: full parallel mise build OOM\'d at 32 GB)', () => { baseTemplate.hasResourceProperties('AWS::ECS::TaskDefinition', { - Cpu: '8192', - Memory: '32768', + Cpu: '16384', + Memory: '65536', RequiresCompatibilities: ['FARGATE'], RuntimePlatform: { CpuArchitecture: 'ARM64', @@ -210,6 +210,9 @@ describe('EcsAgentCluster construct', () => { Match.objectLike({ Name: 'TASK_EVENTS_TABLE_NAME', Value: Match.anyValue() }), Match.objectLike({ Name: 'USER_CONCURRENCY_TABLE_NAME', Value: Match.anyValue() }), Match.objectLike({ Name: 'LOG_GROUP_NAME', Value: Match.anyValue() }), + // K14: ECS big-box substrate raises the build-verify cap so a + // slow-but-healthy CI-parity build isn't mis-flagged as a timeout. + Match.objectLike({ Name: 'BUILD_VERIFY_TIMEOUT_S', Value: '3600' }), ]), }), ]), From 5da571e63b51c2779777285e1e68bfe8eee2f83b Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 1 Jul 2026 10:56:04 -0400 Subject: [PATCH 05/18] fix(ecs): map full payload to run_task so channel/build/cedar fields aren't dropped (ABCA-487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ECS boot command hand-listed ~18 run_task kwargs and silently dropped the rest. On ECS this meant channel_source/channel_metadata never reached run_task, so resolve_linear_api_token never ran, LINEAR_API_TOKEN was never set, and the Linear/Jira reaction + channel MCP silently no-op'd — @bgagent tasks on ECS posted NO 👀 reaction or anything (live-caught: ABCA-487 on the fork, RUNNING with mcp_servers:[] and no reaction). Also dropped: build_command/lint_command (build-gate used defaults), cedar_policies + approval_* (HITL guardrails), base_branch/merge_branches (orchestration stacking), attachments, trace, user_id. Same "hand-rolled partial payload copy" root as #502. Fix — single source of truth, unit-testable: - pipeline.run_task_from_payload(p): maps the WHOLE payload dict to run_task's signature — rename prompt→task_description / model_id→anthropic_model, filter to accepted params (unknown keys ignored, not passed as **kwargs), coerce issue_number/pr_number→str + max_turns→int, aws_region falls back to env. _RUN_TASK_PARAMS computed once at import (patch-safe). - entrypoint exports it; ecs-strategy boot command now calls run_task_from_payload(p) instead of the hand-listed kwargs. - tests: 9 agent tests (rename, channel fields ABCA-487, build/cedar/branch, coercion, unknown-key drop, None-drop, aws_region, signature guard) + ecs-strategy asserts the boot command calls the mapper and no longer hand-lists. Full build green: agent + cdk 2852 + cli 571 + docs. Deployed to dev (--context compute_type=ecs; agent image rebuilt). (cherry picked from commit 777ee0e4f6a5e5e33c4d34c91854f482de177a97) (cherry picked from commit 1639c27ce70808bebdfdda12b94701a54be6fc8c) --- agent/src/entrypoint.py | 2 +- agent/src/pipeline.py | 59 +++++++ agent/tests/test_run_task_from_payload.py | 144 ++++++++++++++++++ .../shared/strategies/ecs-strategy.ts | 30 ++-- .../shared/strategies/ecs-strategy.test.ts | 7 + 5 files changed, 221 insertions(+), 21 deletions(-) create mode 100644 agent/tests/test_run_task_from_payload.py diff --git a/agent/src/entrypoint.py b/agent/src/entrypoint.py index e2097694..53e14830 100644 --- a/agent/src/entrypoint.py +++ b/agent/src/entrypoint.py @@ -31,7 +31,7 @@ TaskResult, TokenUsage, ) -from pipeline import main, run_task # noqa: F401 +from pipeline import main, run_task, run_task_from_payload # noqa: F401 from post_hooks import ( # noqa: F401 ensure_committed, ensure_pr, diff --git a/agent/src/pipeline.py b/agent/src/pipeline.py index a6bb7857..191fd7ee 100644 --- a/agent/src/pipeline.py +++ b/agent/src/pipeline.py @@ -4,6 +4,7 @@ import asyncio import hashlib +import inspect import os import subprocess import sys @@ -1255,6 +1256,64 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: raise +#: Orchestrator payload keys that map to a differently-named ``run_task`` kwarg. +#: The orchestrator emits ``prompt``/``model_id``; ``run_task`` calls them +#: ``task_description``/``anthropic_model``. Everything else is a 1:1 name match. +_PAYLOAD_KEY_ALIASES = { + "prompt": "task_description", + "model_id": "anthropic_model", +} + +#: ``run_task`` kwargs that must be coerced to ``str`` — the orchestrator may +#: emit them as numbers (issue_number, pr_number) and ``run_task`` types them as +#: strings. ``max_turns`` is coerced to int. Absent keys are left to the +#: ``run_task`` defaults. +_PAYLOAD_STR_KEYS = frozenset({"issue_number", "pr_number"}) + +#: Parameter names ``run_task`` accepts — computed once at import from the REAL +#: signature (not inside the function, so patching ``run_task`` in tests can't +#: shadow it). Any payload key not in this set is ignored, never passed through. +_RUN_TASK_PARAMS = frozenset(inspect.signature(run_task).parameters) + + +def run_task_from_payload(payload: dict) -> dict: + """Invoke :func:`run_task` from a full orchestrator payload dict. + + The ECS compute path (``ecs-strategy.ts``) hands the agent the *entire* + orchestrator payload (via the #502 S3 pointer). Previously the ECS boot + command hand-listed a subset of ``run_task`` kwargs and silently dropped the + rest — most visibly ``channel_source``/``channel_metadata`` (no Linear/Jira + reactions or channel MCP on ECS — ABCA-487), plus ``build_command``, + ``cedar_policies``, ``base_branch``/``merge_branches``, ``attachments``, etc. + + This maps the payload to ``run_task``'s real signature so no field can be + silently dropped again: rename the aliased keys, filter to parameters + ``run_task`` actually accepts (unknown keys are ignored, not passed as + ``**kwargs`` which ``run_task`` doesn't accept), and coerce the str/int + fields the orchestrator may emit as numbers. ``aws_region`` falls back to the + ``AWS_REGION`` env var when the payload omits it (the boot command used to + supply this explicitly). + + Single source of truth + unit-testable, replacing the untestable inline + Python string that already drifted once. + """ + kwargs: dict = {} + for key, value in (payload or {}).items(): + target = _PAYLOAD_KEY_ALIASES.get(key, key) + if target not in _RUN_TASK_PARAMS: + continue # not a run_task parameter — ignore (e.g. github_token_secret_arn) + if value is None: + continue # let run_task's default apply + if target in _PAYLOAD_STR_KEYS: + value = str(value) + elif target == "max_turns": + value = int(value) + kwargs[target] = value + + kwargs.setdefault("aws_region", os.environ.get("AWS_REGION", "")) + return run_task(**kwargs) + + def main(): config = get_config() diff --git a/agent/tests/test_run_task_from_payload.py b/agent/tests/test_run_task_from_payload.py new file mode 100644 index 00000000..e9dca722 --- /dev/null +++ b/agent/tests/test_run_task_from_payload.py @@ -0,0 +1,144 @@ +"""Unit tests for pipeline.run_task_from_payload — the ECS payload→run_task map. + +Regression cover for ABCA-487: the ECS boot command used to hand-list a subset +of run_task kwargs and silently dropped channel_source/channel_metadata (no +Linear/Jira reactions on ECS), build_command, cedar_policies, base_branch, etc. +run_task_from_payload maps the WHOLE payload so nothing is dropped again. +""" + +from __future__ import annotations + +from unittest.mock import patch + +from pipeline import _RUN_TASK_PARAMS, run_task_from_payload + + +def _capture(payload: dict) -> dict: + """Run the mapper with run_task replaced by a capturing stub; return kwargs.""" + seen: dict = {} + + def fake_run_task(**kwargs): + seen.update(kwargs) + return {"status": "success"} + + with patch("pipeline.run_task", side_effect=fake_run_task): + run_task_from_payload(payload) + return seen + + +class TestRunTaskFromPayload: + def test_renames_prompt_and_model_id(self): + seen = _capture({"prompt": "do the thing", "model_id": "anthropic.claude-x"}) + assert seen["task_description"] == "do the thing" + assert seen["anthropic_model"] == "anthropic.claude-x" + # The original payload keys must NOT leak through as-is (run_task rejects them). + assert "prompt" not in seen + assert "model_id" not in seen + + def test_forwards_channel_fields_ABCA_487(self): + # THE regression: channel_source/channel_metadata must reach run_task so + # the Linear/Jira reaction + channel MCP fire on ECS. + cm = {"linear_issue_id": "iss-1", "linear_oauth_secret_arn": "arn:sm:...:lin"} + seen = _capture({"channel_source": "linear", "channel_metadata": cm}) + assert seen["channel_source"] == "linear" + assert seen["channel_metadata"] == cm + + def test_forwards_cedar_attachments_trace_user_fields(self): + # HITL guardrails (cedar_policies, approval_*), attachments, trace, and + # user_id are real run_task params here — they must reach run_task on ECS + # (the hand-listed boot command used to drop them). + seen = _capture( + { + "cedar_policies": ["p1", "p2"], + "attachments": [{"filename": "x.png"}], + "trace": True, + "user_id": "user-9", + } + ) + assert seen["cedar_policies"] == ["p1", "p2"] + assert seen["attachments"] == [{"filename": "x.png"}] + assert seen["trace"] is True + assert seen["user_id"] == "user-9" + + def test_drops_payload_keys_that_are_not_yet_run_task_params(self): + # build_command/lint_command (configurable verify, #1) and base_branch/ + # merge_branches (orchestration stacking, #247) are emitted by the + # orchestrator but are NOT run_task parameters on this branch. The mapper + # filters against run_task's REAL signature, so they are dropped rather + # than smuggled through as an invalid kwarg. When those params land, + # they forward automatically with no change here — that's the point of + # keying off inspect.signature instead of a hand-list. + seen = _capture( + { + "repo_url": "org/repo", + "build_command": "npm ci && npm test", + "lint_command": "npm run lint", + "base_branch": "epic-tip", + "merge_branches": ["a", "b"], + } + ) + assert seen["repo_url"] == "org/repo" + for not_yet in ("build_command", "lint_command", "base_branch", "merge_branches"): + assert not_yet not in seen + + def test_coerces_issue_and_pr_number_to_str_and_max_turns_to_int(self): + seen = _capture({"issue_number": 42, "pr_number": 7, "max_turns": "50"}) + assert seen["issue_number"] == "42" + assert seen["pr_number"] == "7" + assert seen["max_turns"] == 50 + assert isinstance(seen["max_turns"], int) + + def test_ignores_unknown_payload_keys(self): + # github_token_secret_arn is on the payload but is NOT a run_task param + # (it's consumed platform-side); passing it as **kwargs would TypeError. + seen = _capture( + { + "repo_url": "org/repo", + "github_token_secret_arn": "arn:...", + "sources": ["x"], + } + ) + assert seen["repo_url"] == "org/repo" + assert "github_token_secret_arn" not in seen + assert "sources" not in seen + + def test_drops_none_values_so_run_task_defaults_apply(self): + seen = _capture({"repo_url": "org/repo", "base_branch": None, "channel_metadata": None}) + assert "base_branch" not in seen + assert "channel_metadata" not in seen + + def test_aws_region_falls_back_to_env(self, monkeypatch): + monkeypatch.setenv("AWS_REGION", "us-east-1") + seen = _capture({"repo_url": "org/repo"}) + assert seen["aws_region"] == "us-east-1" + + def test_explicit_aws_region_in_payload_wins(self, monkeypatch): + monkeypatch.setenv("AWS_REGION", "us-east-1") + seen = _capture({"repo_url": "org/repo", "aws_region": "eu-west-1"}) + assert seen["aws_region"] == "eu-west-1" + + def test_every_forwarded_key_is_a_real_run_task_param(self): + # Guard: whatever the mapper forwards must be accepted by run_task, so a + # future payload key can never smuggle an invalid kwarg through. Compare + # against the module's real param set (run_task is patched in _capture). + accepted = _RUN_TASK_PARAMS + seen = _capture( + { + "prompt": "p", + "model_id": "m", + "repo_url": "r", + "issue_number": 1, + "channel_source": "linear", + "channel_metadata": {"a": "b"}, + "build_command": "b", + "cedar_policies": ["c"], + "base_branch": "x", + "attachments": [{}], + "trace": False, + "user_id": "u", + "pr_number": 3, + "hydrated_context": {"k": "v"}, + "resolved_workflow": {"id": "w"}, + } + ) + assert set(seen).issubset(accepted) diff --git a/cdk/src/handlers/shared/strategies/ecs-strategy.ts b/cdk/src/handlers/shared/strategies/ecs-strategy.ts index df70e8a2..20e80f13 100644 --- a/cdk/src/handlers/shared/strategies/ecs-strategy.ts +++ b/cdk/src/handlers/shared/strategies/ecs-strategy.ts @@ -169,38 +169,28 @@ export class EcsComputeStrategy implements ComputeStrategy { // Override the container command to run a Python one-liner that: // 1. Loads the payload — from S3 (AGENT_PAYLOAD_S3_URI) when set, else the // inline AGENT_PAYLOAD env var (fallback). - // 2. Calls entrypoint.run_task() directly with all fields. + // 2. Calls entrypoint.run_task_from_payload(p), which maps the WHOLE payload + // dict to run_task's signature (rename prompt→task_description / + // model_id→anthropic_model, filter to accepted params, coerce str/int). + // This replaces the old hand-listed kwarg subset that silently dropped + // channel_source/channel_metadata (no Linear/Jira reactions or channel + // MCP on ECS — ABCA-487), build_command, cedar_policies, base_branch/ + // merge_branches, attachments, trace, user_id, etc. Single source of + // truth in the agent, unit-tested (see test_run_task_from_payload). // 3. Exits with code 0 on success, 1 on failure. // This bypasses the uvicorn server entirely — no HTTP, no OTEL noise. const bootCommand = [ 'python', '-c', 'import json, os, sys; ' + 'sys.path.insert(0, "/app/src"); ' - + 'from entrypoint import run_task; ' + + 'from entrypoint import run_task_from_payload; ' + '_uri = os.environ.get("AGENT_PAYLOAD_S3_URI"); ' + 'p = (' + 'json.loads(__import__("boto3").client("s3").get_object(' + 'Bucket=_uri.split("/",3)[2], Key=_uri.split("/",3)[3])["Body"].read()) ' + 'if _uri else json.loads(os.environ["AGENT_PAYLOAD"])' + '); ' - + 'r = run_task(' - + 'repo_url=p.get("repo_url",""), ' - + 'task_description=p.get("prompt",""), ' - + 'issue_number=str(p.get("issue_number","")), ' - + 'github_token=p.get("github_token",""), ' - + 'anthropic_model=p.get("model_id",""), ' - + 'max_turns=int(p.get("max_turns",100)), ' - + 'max_budget_usd=p.get("max_budget_usd"), ' - + 'aws_region=os.environ.get("AWS_REGION",""), ' - + 'task_id=p.get("task_id",""), ' - + 'hydrated_context=p.get("hydrated_context"), ' - + 'system_prompt_overrides=p.get("system_prompt_overrides",""), ' - + 'prompt_version=p.get("prompt_version",""), ' - + 'memory_id=p.get("memory_id",""), ' - + 'resolved_workflow=p.get("resolved_workflow"), ' - + 'branch_name=p.get("branch_name",""), ' - + 'pr_number=str(p.get("pr_number",""))' - + '); ' + + 'r = run_task_from_payload(p); ' + 'sys.exit(0 if r.get("status")=="success" else 1)', ]; diff --git a/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts b/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts index bee9c41a..617d2e1f 100644 --- a/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts +++ b/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts @@ -423,6 +423,13 @@ describe('EcsComputeStrategy with ECS_PAYLOAD_BUCKET (S3-pointer path, #502)', ( expect(src).toContain('AGENT_PAYLOAD_S3_URI'); expect(src).toContain('get_object'); expect(src).toContain('AGENT_PAYLOAD'); + // ABCA-487: the boot command maps the WHOLE payload via + // run_task_from_payload (not a hand-listed kwarg subset that dropped + // channel_source/channel_metadata → no Linear reactions on ECS). Assert we + // call the mapper and no longer hand-pick the old prompt/model_id kwargs. + expect(src).toContain('run_task_from_payload(p)'); + expect(src).not.toContain('task_description=p.get'); + expect(src).not.toContain('channel_source'); // never hand-listed; the mapper forwards it }); test('deleteEcsPayload deletes the task payload object', async () => { From bf0a86d4b3afaefafb524e25007ba927204a5149 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 1 Jul 2026 12:36:02 -0400 Subject: [PATCH 06/18] fix(ecs): grant task role GetSecretValue on bgagent-{linear,jira}-oauth-* (ABCA-488) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Linear/Jira-channel task resolves its per-workspace OAuth token from Secrets Manager (bgagent-linear-oauth-) at startup to fire the 👀→✅ reaction and drive the channel MCP. The AgentCore runtime role + orchestrator/fanout/ screenshot/linear-integration roles all have the bgagent-linear-oauth-* prefix grant, but the ECS task role only had GetSecretValue on the GitHub token. So on ECS the token fetch hit AccessDenied → LINEAR_API_TOKEN unset → linear_reactions skipped AND the Linear MCP stayed needs-auth (agent couldn't post comments either). Live-caught dogfooding on the fork (ABCA-488, ECS task 828dab35). Fourth ECS-parity gap after #502 (payload size) + ABCA-487 (dropped channel fields): the AgentCore path had a capability the ECS task role didn't. Fix: grant the ECS task role secretsmanager:GetSecretValue on the bgagent-linear-oauth-* and bgagent-jira-oauth-* prefixes (GetSecretValue only — the container reads; the orchestrator owns refresh/PutSecretValue). Nag reason updated + regression test asserting the prefix grant. Full build green (2853). (cherry picked from commit 5a886bbd0b90c14a0916bda2f72d5f7a8420d351) (cherry picked from commit 49235d4302815d8aea421a2bc57225ceaa5621d6) --- cdk/src/constructs/ecs-agent-cluster.ts | 30 ++++++++++++++++++- cdk/test/constructs/ecs-agent-cluster.test.ts | 19 ++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/cdk/src/constructs/ecs-agent-cluster.ts b/cdk/src/constructs/ecs-agent-cluster.ts index eb2c51f8..39bf1b91 100644 --- a/cdk/src/constructs/ecs-agent-cluster.ts +++ b/cdk/src/constructs/ecs-agent-cluster.ts @@ -194,6 +194,34 @@ export class EcsAgentCluster extends Construct { props.payloadBucket.grantRead(taskRole); } + // ABCA-488: per-workspace Linear/Jira OAuth tokens live in Secrets Manager + // under `bgagent-linear-oauth-*` (written by the CLI at setup). For a + // Linear/Jira-channel task the agent resolves that token at startup + // (config.resolve_linear_api_token / resolve_jira_oauth_token) to fire the + // 👀→✅ reaction and drive the channel MCP. The AgentCore runtime role + + // orchestrator/fanout/screenshot roles all have this prefix grant; the ECS + // task role did NOT, so on ECS the token fetch hit AccessDenied and + // reactions/MCP silently no-op'd (ECS-parity gap, live-caught on ABCA-488). + // GetSecretValue only — the container reads the token; the orchestrator owns + // refresh/PutSecretValue. + taskRole.addToPrincipalPolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:GetSecretValue'], + resources: [ + Stack.of(this).formatArn({ + service: 'secretsmanager', + resource: 'secret', + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + resourceName: 'bgagent-linear-oauth-*', + }), + Stack.of(this).formatArn({ + service: 'secretsmanager', + resource: 'secret', + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + resourceName: 'bgagent-jira-oauth-*', + }), + ], + })); + // Bedrock model invocation — scoped to explicit foundation-model and // cross-region inference-profile ARNs (parity with the AgentCore runtime // grants in agent.ts), NOT a Resource: '*' wildcard. The model set is the @@ -237,7 +265,7 @@ export class EcsAgentCluster extends Construct { NagSuppressions.addResourceSuppressions(this.taskDefinition, [ { id: 'AwsSolutions-IAM5', - reason: 'DynamoDB index/* wildcards from CDK grantReadWriteData (UserConcurrencyTable, and task tables only when no SessionRole is wired); Secrets Manager wildcards from CDK grantRead; CloudWatch Logs wildcards from CDK grantWrite; S3 object/* wildcard from CDK grantRead on the ECS payload bucket (read-only, scoped to that bucket — #502). Bedrock InvokeModel is scoped to explicit model/inference-profile ARNs (no wildcard resource).', + reason: 'DynamoDB index/* wildcards from CDK grantReadWriteData (UserConcurrencyTable, and task tables only when no SessionRole is wired); Secrets Manager wildcards from CDK grantRead (GitHub token) and the bgagent-linear-oauth-*/bgagent-jira-oauth-* prefix grant (ABCA-488 — per-workspace channel OAuth tokens are created by the CLI at setup, name unknown at synth, GetSecretValue only); CloudWatch Logs wildcards from CDK grantWrite; S3 object/* wildcard from CDK grantRead on the ECS payload bucket (read-only, scoped to that bucket — #502). Bedrock InvokeModel is scoped to explicit model/inference-profile ARNs (no wildcard resource).', }, { id: 'AwsSolutions-ECS2', diff --git a/cdk/test/constructs/ecs-agent-cluster.test.ts b/cdk/test/constructs/ecs-agent-cluster.test.ts index ce5716e7..ce0b93bb 100644 --- a/cdk/test/constructs/ecs-agent-cluster.test.ts +++ b/cdk/test/constructs/ecs-agent-cluster.test.ts @@ -155,6 +155,25 @@ describe('EcsAgentCluster construct', () => { }); }); + test('task role can read the per-workspace Linear/Jira OAuth secrets (ABCA-488)', () => { + // REGRESSION: a Linear/Jira-channel task resolves its per-workspace OAuth + // token (bgagent-linear-oauth-) at startup to fire the 👀→✅ reaction + // and drive the channel MCP. Without a prefix grant on the ECS task role the + // fetch hit AccessDenied and reactions/MCP silently no-op'd on ECS (worked on + // AgentCore). Pin a GetSecretValue statement whose resource ARN names the + // bgagent-linear-oauth-* prefix. + const policies = baseTemplate.findResources('AWS::IAM::Policy'); + let hasLinearOauthGrant = false; + for (const p of Object.values(policies)) { + for (const s of p.Properties.PolicyDocument.Statement) { + const actions = Array.isArray(s.Action) ? s.Action : [s.Action]; + if (!actions.includes('secretsmanager:GetSecretValue')) continue; + if (JSON.stringify(s.Resource).includes('bgagent-linear-oauth-')) hasLinearOauthGrant = true; + } + } + expect(hasLinearOauthGrant).toBe(true); + }); + test('task role Bedrock InvokeModel is scoped to explicit model/inference-profile ARNs (no wildcard)', () => { const policies = baseTemplate.findResources('AWS::IAM::Policy'); let bedrockStatement: { Resource: unknown } | undefined; From 70efb8d440815f6f902269bc977cdbabccd6bd34 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Mon, 6 Jul 2026 15:57:15 -0400 Subject: [PATCH 07/18] fix(ecs): wire ARTIFACTS_BUCKET_NAME into the Fargate task (ECS-parity, #299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-caught: a :decompose on an ecs-configured repo ran on ECS (fix confirmed — no more 'ECS_CLUSTER_ARN missing') but then FAILED at delivery with 'ValueError: deliver_artifact: ARTIFACTS_BUCKET_NAME is not configured'. The AgentCore runtime sets ARTIFACTS_BUCKET_NAME; the EcsAgentCluster task def never got the bucket — an ECS-parity gap (same class as #502/ABCA-487). A repo-bound artifact workflow (coding/decompose-v1) delivers its plan JSON there. - EcsAgentCluster: new optional artifactsBucket prop → ARTIFACTS_BUCKET_NAME in the container env + grantReadWrite on the task role (read+write: the container DELIVERS the artifact, unlike the read-only payload bucket). IAM5 suppression extended to cover the second scoped S3 grant. - agent.ts: pass traceArtifactsBucket.bucket (same bucket the runtime uses). - 3 construct tests (env injected / read+write grant / omitted when absent). 2872 CDK + 571 CLI + 1184 agent green. Held on branch. (cherry picked from commit 7617d727f02cfca2a0b8a9e5e6ef413b69f2c692) (cherry picked from commit e0dfc93559ed90f569e1f4718a00e745e7c17898) --- cdk/src/constructs/ecs-agent-cluster.ts | 27 ++++++- cdk/test/constructs/ecs-agent-cluster.test.ts | 71 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/cdk/src/constructs/ecs-agent-cluster.ts b/cdk/src/constructs/ecs-agent-cluster.ts index 39bf1b91..a0a4c26e 100644 --- a/cdk/src/constructs/ecs-agent-cluster.ts +++ b/cdk/src/constructs/ecs-agent-cluster.ts @@ -52,6 +52,18 @@ export interface EcsAgentClusterProps { */ readonly payloadBucket?: s3.IBucket; + /** + * Artifacts bucket for repo-bound artifact workflows (#299 coding/decompose-v1 + * emits its plan JSON here via ``deliver_artifact``; also the ``--trace`` + * upload target). The AgentCore runtime gets ``ARTIFACTS_BUCKET_NAME`` in its + * env; the ECS task needs the SAME env + read/write grant or an artifact + * workflow fails at delivery with "ARTIFACTS_BUCKET_NAME is not configured" + * (live-caught: a :decompose on an ecs-configured repo). Read/WRITE because the + * container DELIVERS the artifact (unlike the read-only payload bucket). + * Omitted in isolated construct tests → no env/grant. + */ + readonly artifactsBucket?: s3.IBucket; + /** * Per-task SessionRole (#209). When provided, tenant-data DynamoDB access * (task/events tables) is NOT granted to the Fargate task role; instead the @@ -157,6 +169,11 @@ export class EcsAgentCluster extends Construct { // AGENT_PAYLOAD_S3_URI can be fetched. (The orchestrator sets the URI // per-task via container override; this is informational parity.) ...(props.payloadBucket && { ECS_PAYLOAD_BUCKET: props.payloadBucket.bucketName }), + // #299 ECS-parity: artifact workflows (coding/decompose-v1) deliver their + // plan JSON to this bucket. The AgentCore runtime has ARTIFACTS_BUCKET_NAME; + // the ECS task needs it too or deliver_artifact raises "ARTIFACTS_BUCKET_NAME + // is not configured" (live-caught on an ecs-repo :decompose). + ...(props.artifactsBucket && { ARTIFACTS_BUCKET_NAME: props.artifactsBucket.bucketName }), // Per-session IAM scoping (#209): when a SessionRole is wired, the // agent assumes it for tenant-data access (see aws_session.py). ...(props.agentSessionRole && { @@ -194,6 +211,14 @@ export class EcsAgentCluster extends Construct { props.payloadBucket.grantRead(taskRole); } + // #299 ECS-parity: an artifact workflow (coding/decompose-v1) WRITES its plan + // to the artifacts bucket via deliver_artifact, so grant read+write (the + // AgentCore runtime's SessionRole/exec-role has the equivalent). Scoped to + // this bucket. Stays on the task role — delivery is a terminal step. + if (props.artifactsBucket) { + props.artifactsBucket.grantReadWrite(taskRole); + } + // ABCA-488: per-workspace Linear/Jira OAuth tokens live in Secrets Manager // under `bgagent-linear-oauth-*` (written by the CLI at setup). For a // Linear/Jira-channel task the agent resolves that token at startup @@ -265,7 +290,7 @@ export class EcsAgentCluster extends Construct { NagSuppressions.addResourceSuppressions(this.taskDefinition, [ { id: 'AwsSolutions-IAM5', - reason: 'DynamoDB index/* wildcards from CDK grantReadWriteData (UserConcurrencyTable, and task tables only when no SessionRole is wired); Secrets Manager wildcards from CDK grantRead (GitHub token) and the bgagent-linear-oauth-*/bgagent-jira-oauth-* prefix grant (ABCA-488 — per-workspace channel OAuth tokens are created by the CLI at setup, name unknown at synth, GetSecretValue only); CloudWatch Logs wildcards from CDK grantWrite; S3 object/* wildcard from CDK grantRead on the ECS payload bucket (read-only, scoped to that bucket — #502). Bedrock InvokeModel is scoped to explicit model/inference-profile ARNs (no wildcard resource).', + reason: 'DynamoDB index/* wildcards from CDK grantReadWriteData (UserConcurrencyTable, and task tables only when no SessionRole is wired); Secrets Manager wildcards from CDK grantRead (GitHub token) and the bgagent-linear-oauth-*/bgagent-jira-oauth-* prefix grant (ABCA-488 — per-workspace channel OAuth tokens are created by the CLI at setup, name unknown at synth, GetSecretValue only); CloudWatch Logs wildcards from CDK grantWrite; S3 object/* wildcard from CDK grantRead on the ECS payload bucket (read-only, scoped to that bucket — #502) and from grantReadWrite on the artifacts bucket (scoped to that bucket — coding/decompose-v1 delivers its plan artifact there, #299). Bedrock InvokeModel is scoped to explicit model/inference-profile ARNs (no wildcard resource).', }, { id: 'AwsSolutions-ECS2', diff --git a/cdk/test/constructs/ecs-agent-cluster.test.ts b/cdk/test/constructs/ecs-agent-cluster.test.ts index ce0b93bb..aa06b362 100644 --- a/cdk/test/constructs/ecs-agent-cluster.test.ts +++ b/cdk/test/constructs/ecs-agent-cluster.test.ts @@ -432,3 +432,74 @@ describe('EcsAgentCluster payload bucket (#502)', () => { } }); }); + +describe('EcsAgentCluster artifacts bucket (#299 ECS-parity)', () => { + function createWithArtifactsBucket(): Template { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + const vpc = new ec2.Vpc(stack, 'Vpc', { maxAzs: 2 }); + const agentImageAsset = new ecr_assets.DockerImageAsset(stack, 'AgentImage', { + directory: path.join(__dirname, '..', '..', '..', 'agent'), + }); + const taskTable = new dynamodb.Table(stack, 'TaskTable', { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + }); + const taskEventsTable = new dynamodb.Table(stack, 'TaskEventsTable', { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'event_id', type: dynamodb.AttributeType.STRING }, + }); + const userConcurrencyTable = new dynamodb.Table(stack, 'UserConcurrencyTable', { + partitionKey: { name: 'user_id', type: dynamodb.AttributeType.STRING }, + }); + const githubTokenSecret = new secretsmanager.Secret(stack, 'GitHubTokenSecret'); + const artifactsBucket = new s3.Bucket(stack, 'ArtifactsBucket'); + + new EcsAgentCluster(stack, 'EcsAgentCluster', { + vpc, + agentImageAsset, + taskTable, + taskEventsTable, + userConcurrencyTable, + githubTokenSecret, + artifactsBucket, + }); + return Template.fromStack(stack); + } + + test('injects ARTIFACTS_BUCKET_NAME into the container env (parity with the AgentCore runtime)', () => { + createWithArtifactsBucket().hasResourceProperties('AWS::ECS::TaskDefinition', { + ContainerDefinitions: Match.arrayWith([ + Match.objectLike({ + Environment: Match.arrayWith([ + Match.objectLike({ Name: 'ARTIFACTS_BUCKET_NAME', Value: Match.anyValue() }), + ]), + }), + ]), + }); + }); + + test('grants the task role READ + WRITE on the artifacts bucket (it delivers the plan artifact)', () => { + const template = createWithArtifactsBucket(); + const policies = template.findResources('AWS::IAM::Policy'); + const s3Actions = new Set(); + for (const policy of Object.values(policies)) { + for (const stmt of policy.Properties.PolicyDocument.Statement) { + const actions = Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action]; + for (const a of actions) { + if (typeof a === 'string' && a.startsWith('s3:')) s3Actions.add(a); + } + } + } + expect([...s3Actions].some(a => a === 's3:GetObject' || a === 's3:GetObject*')).toBe(true); + expect([...s3Actions].some(a => a === 's3:PutObject' || a === 's3:PutObject*')).toBe(true); + }); + + test('omits ARTIFACTS_BUCKET_NAME when no artifacts bucket is provided', () => { + const { template } = createStack(); + const taskDefs = template.findResources('AWS::ECS::TaskDefinition'); + for (const def of Object.values(taskDefs)) { + const env = def.Properties.ContainerDefinitions[0].Environment ?? []; + expect(env.some((e: { Name: string }) => e.Name === 'ARTIFACTS_BUCKET_NAME')).toBe(false); + } + }); +}); From b9da9373516e9943a824143f1ffd66af0577a1ca Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Mon, 6 Jul 2026 16:46:06 -0400 Subject: [PATCH 08/18] feat(compute): guard compute_type=ecs against a stack without the ECS substrate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A repo onboarded as compute_type=ecs on a stack deployed WITHOUT --context compute_type=ecs fails every task at session start (no ECS_* env vars). Catch the config/deploy mismatch early + make the runtime error actionable: - agent.ts: new ComputeSubstrate CfnOutput — 'ecs' when the gate is on, else 'agentcore' (ecs is additive; the AgentCore runtime is always present, so an agentcore repo works on either). - cli repo onboard: when --compute-type ecs, read ComputeSubstrate and refuse with a redeploy message if it's an explicit non-ecs value. Null (older stacks predating the output) is treated as unknown → proceed (runtime backstop). - ecs-strategy: the missing-env error now names the root cause + remedy (redeploy with the gate, or set the repo to agentcore) instead of a bare env-var list. - Tests: CDK output flips with the gate (agentcore default / ecs gated); CLI onboard refuses/allows/back-commpat; ecs construct unchanged. 2875 CDK + 575 CLI + 1184 agent green. Held on branch. (cherry picked from commit cb7c8c66b9c9c169541299d83684d885a22a4de8) (cherry picked from commit 9048db03f157d92371442ced5dc6fccb105eec5e) --- .../shared/strategies/ecs-strategy.ts | 11 +++- cdk/test/stacks/agent.test.ts | 29 ++++++++++ cli/src/commands/repo.ts | 18 ++++++- cli/test/commands/repo.test.ts | 53 +++++++++++++++++++ 4 files changed, 109 insertions(+), 2 deletions(-) diff --git a/cdk/src/handlers/shared/strategies/ecs-strategy.ts b/cdk/src/handlers/shared/strategies/ecs-strategy.ts index 20e80f13..a45ef129 100644 --- a/cdk/src/handlers/shared/strategies/ecs-strategy.ts +++ b/cdk/src/handlers/shared/strategies/ecs-strategy.ts @@ -98,8 +98,17 @@ export class EcsComputeStrategy implements ComputeStrategy { blueprintConfig: BlueprintConfig; }): Promise { if (!ECS_CLUSTER_ARN || !ECS_TASK_DEFINITION_ARN || !ECS_SUBNETS || !ECS_SECURITY_GROUP) { + // Config/deploy mismatch: this repo is compute_type=ecs but the stack was + // deployed WITHOUT the ECS substrate (no `--context compute_type=ecs`), so + // the orchestrator has no ECS_* env vars. Name the root cause + remedy so an + // admin doesn't have to reverse-engineer it from a bare env-var list. (The + // CLI `repo onboard --compute-type ecs` guard normally prevents this; a repo + // onboarded before that guard, or edited directly, can still reach here.) throw new Error( - 'ECS compute strategy requires ECS_CLUSTER_ARN, ECS_TASK_DEFINITION_ARN, ECS_SUBNETS, and ECS_SECURITY_GROUP environment variables', + 'This repository is configured compute_type=ecs, but this stack was deployed without the ECS ' + + 'substrate (missing ECS_CLUSTER_ARN/ECS_TASK_DEFINITION_ARN/ECS_SUBNETS/ECS_SECURITY_GROUP). ' + + 'Redeploy the stack with `--context compute_type=ecs` to provision the Fargate substrate, or ' + + 'set this repo to compute_type=agentcore (bgagent repo onboard --compute-type agentcore).', ); } diff --git a/cdk/test/stacks/agent.test.ts b/cdk/test/stacks/agent.test.ts index 3b8e6249..7c2610ee 100644 --- a/cdk/test/stacks/agent.test.ts +++ b/cdk/test/stacks/agent.test.ts @@ -69,6 +69,12 @@ describe('AgentStack', () => { }); }); + test('outputs ComputeSubstrate=agentcore on the default (no-gate) deploy', () => { + // The CLI reads this to refuse onboarding a repo as compute_type=ecs on a + // stack that never provisioned the ECS substrate. + template.hasOutput('ComputeSubstrate', { Value: 'agentcore' }); + }); + test('outputs CedarWasmLayerArn', () => { template.hasOutput('CedarWasmLayerArn', {}); }); @@ -502,3 +508,26 @@ describe('AgentStack', () => { }); }); }); + +describe('AgentStack with the ECS substrate gate (--context compute_type=ecs)', () => { + let template: Template; + + beforeAll(() => { + // Deploying with the gate on provisions the Fargate substrate alongside the + // always-present AgentCore runtime; the ComputeSubstrate output flips to 'ecs'. + const app = new App({ context: { compute_type: 'ecs' } }); + const stack = new AgentStack(app, 'TestAgentStackEcs', { + env: { account: '123456789012', region: 'us-east-1' }, + }); + template = Template.fromStack(stack); + }); + + test('provisions an ECS cluster + Fargate task definition', () => { + template.resourceCountIs('AWS::ECS::Cluster', 1); + template.resourceCountIs('AWS::ECS::TaskDefinition', 1); + }); + + test('outputs ComputeSubstrate=ecs so the CLI allows compute_type=ecs onboarding', () => { + template.hasOutput('ComputeSubstrate', { Value: 'ecs' }); + }); +}); diff --git a/cli/src/commands/repo.ts b/cli/src/commands/repo.ts index d037934f..94a58f3d 100644 --- a/cli/src/commands/repo.ts +++ b/cli/src/commands/repo.ts @@ -166,16 +166,32 @@ export function makeRepoCommand(): Command { } const { region, stackName } = resolveOperatorContext(opts); - const [tableName, platformRuntimeArn, platformGithubTokenSecretArn] = await Promise.all([ + const [tableName, platformRuntimeArn, platformGithubTokenSecretArn, computeSubstrate] = await Promise.all([ getStackOutput(region, stackName, 'RepoTableName'), getStackOutput(region, stackName, 'RuntimeArn'), getStackOutput(region, stackName, 'GitHubTokenSecretArn'), + getStackOutput(region, stackName, 'ComputeSubstrate'), ]); if (!tableName) { throw new CliError( `Stack '${stackName}' is missing output 'RepoTableName'. Re-deploy the CDK stack.`, ); } + // Refuse to onboard a repo as compute_type=ecs when the deployed stack did + // NOT provision the ECS substrate — otherwise every task on this repo fails + // at session start with "ECS compute strategy requires ECS_CLUSTER_ARN…". + // Catch it here, at config time, with a fixable message. ComputeSubstrate is + // null on stacks predating this output; treat that as "unknown" and only + // hard-block on an explicit non-ecs value, so onboarding still works against + // an older deploy (the runtime error remains the backstop there). + if (opts.computeType === 'ecs' && computeSubstrate && computeSubstrate !== 'ecs') { + throw new CliError( + `Stack '${stackName}' was deployed without the ECS substrate (ComputeSubstrate=${computeSubstrate}), ` + + 'so a repo onboarded as --compute-type ecs would fail at task start. Redeploy the stack with ' + + '`--context compute_type=ecs` first (adds the Fargate substrate alongside AgentCore), then re-run this — ' + + 'or onboard with --compute-type agentcore.', + ); + } const config = await onboardRepo(region, tableName, repoId, { computeType: opts.computeType, diff --git a/cli/test/commands/repo.test.ts b/cli/test/commands/repo.test.ts index 17a8fd4d..cc115feb 100644 --- a/cli/test/commands/repo.test.ts +++ b/cli/test/commands/repo.test.ts @@ -166,6 +166,59 @@ describe('repo command JSON output', () => { expect(payload.repo.github_token_secret_arn).toContain('****'); }); + test('onboard --compute-type ecs is REFUSED when the stack has no ECS substrate', async () => { + // Per-key outputs: RepoTableName present, ComputeSubstrate=agentcore (deployed + // without --context compute_type=ecs). + getStackOutputMock.mockReset().mockImplementation((_r: string, _s: string, key: string) => + Promise.resolve(key === 'ComputeSubstrate' ? 'agentcore' : 'RepoTable-dev')); + + const cmd = makeRepoCommand(); + await expect(cmd.parseAsync([ + 'node', 'test', 'onboard', 'acme/a', '--region', 'us-east-1', '--compute-type', 'ecs', + ])).rejects.toThrow(/without the ECS substrate|compute_type=ecs/i); + // Must NOT write the repo row when it would be dead-on-arrival. + expect(onboardRepoMock).not.toHaveBeenCalled(); + }); + + test('onboard --compute-type ecs is ALLOWED when the stack provisioned ECS', async () => { + getStackOutputMock.mockReset().mockImplementation((_r: string, _s: string, key: string) => + Promise.resolve(key === 'ComputeSubstrate' ? 'ecs' : 'RepoTable-dev')); + onboardRepoMock.mockResolvedValue({ repo: 'acme/a', status: 'active', compute_type: 'ecs' }); + + const cmd = makeRepoCommand(); + await cmd.parseAsync([ + 'node', 'test', 'onboard', 'acme/a', '--region', 'us-east-1', '--compute-type', 'ecs', + ]); + expect(onboardRepoMock).toHaveBeenCalledWith( + 'us-east-1', 'RepoTable-dev', 'acme/a', expect.objectContaining({ computeType: 'ecs' })); + }); + + test('onboard --compute-type ecs proceeds against an OLDER stack lacking ComputeSubstrate (null → unknown)', async () => { + // Back-compat: pre-output stacks return null for ComputeSubstrate; don't hard-block + // (the runtime error is the backstop there). + getStackOutputMock.mockReset().mockImplementation((_r: string, _s: string, key: string) => + Promise.resolve(key === 'ComputeSubstrate' ? null : 'RepoTable-dev')); + onboardRepoMock.mockResolvedValue({ repo: 'acme/a', status: 'active', compute_type: 'ecs' }); + + const cmd = makeRepoCommand(); + await cmd.parseAsync([ + 'node', 'test', 'onboard', 'acme/a', '--region', 'us-east-1', '--compute-type', 'ecs', + ]); + expect(onboardRepoMock).toHaveBeenCalled(); + }); + + test('onboard --compute-type agentcore is unaffected by ComputeSubstrate', async () => { + getStackOutputMock.mockReset().mockImplementation((_r: string, _s: string, key: string) => + Promise.resolve(key === 'ComputeSubstrate' ? 'agentcore' : 'RepoTable-dev')); + onboardRepoMock.mockResolvedValue({ repo: 'acme/a', status: 'active', compute_type: 'agentcore' }); + + const cmd = makeRepoCommand(); + await cmd.parseAsync([ + 'node', 'test', 'onboard', 'acme/a', '--region', 'us-east-1', '--compute-type', 'agentcore', + ]); + expect(onboardRepoMock).toHaveBeenCalled(); + }); + test('repo offboard --output json redacts the per-repo secret ARN', async () => { offboardRepoMock.mockResolvedValue({ repo: 'acme/a', From 26ec1504bbf4ebab5c4cefc48d92f25d2358fc20 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Thu, 9 Jul 2026 13:40:53 -0400 Subject: [PATCH 09/18] =?UTF-8?q?fix(cdk):=20raise=20BUILD=20task=20memory?= =?UTF-8?q?=2064=E2=86=92120=20GB=20(max=20Fargate)=20=E2=80=94=20ABCA-662?= =?UTF-8?q?=20baseline=20OOM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ABCA-662's pre-agent baseline build was OOM-killed (exit 137) at 64 GB: dogfooding ABCA-on-ABCA, the full parallel `mise run build` (agent:quality ‖ cdk:build ‖ cli:build ‖ docs:build, each with its own worker fleet) peaked past 64 GB. Each build task is memory-ISOLATED (its own Fargate microVM), so limiting concurrency does NOT help a single over-64 GB build — only more per-task RAM or less build parallelism does. 120 GB is the MAX Fargate admits at 16 vCPU (32–120 in 8 GB steps), so this is the clean experiment: if a build still OOMs here, the only remaining lever is cutting the build's peak parallelism (serialize the mise DAG / cap jest --maxWorkers) — there is no more RAM to give. Pairs with the repo.py fix (an OOM-killed baseline is now infra, not 'already broken'), so even if a build does hit the ceiling the verdict stays honest. Tests updated to assert Memory 122880 (both BUILD-def assertions). (cherry picked from commit 25e2bb1f152f3ca81aa6a9a795e56949ceea1f7b) --- cdk/src/constructs/ecs-agent-cluster.ts | 15 +++++++++------ cdk/test/constructs/ecs-agent-cluster.test.ts | 4 ++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/cdk/src/constructs/ecs-agent-cluster.ts b/cdk/src/constructs/ecs-agent-cluster.ts index a0a4c26e..0115d74d 100644 --- a/cdk/src/constructs/ecs-agent-cluster.ts +++ b/cdk/src/constructs/ecs-agent-cluster.ts @@ -130,14 +130,17 @@ export class EcsAgentCluster extends Construct { // in PARALLEL (agent:quality ‖ cdk:build ‖ cli:build ‖ docs:build), // each spawning its own worker fleet (jest maxWorkers, pytest, esbuild // Lambda bundling). 32 GB had no headroom for that concurrent peak. - // - 64 GB / 16 vCPU (current) → 2× the memory headroom for the parallel - // storm, and 16 vCPU shortens wall-clock (paired with - // BUILD_VERIFY_TIMEOUT_S=3600 below so the ~longer-than-30-min build - // isn't mis-reported as a timeout). Valid Fargate ARM64 combo (16 vCPU - // admits 32–120 GB in 8 GB steps). + // - 64 GB / 16 vCPU → still OOM-killed (exit 137) on ABCA-662's baseline + // build: the parallel storm's peak exceeded 64 GB too. The false + // "build_before=broken" that followed is fixed in repo.py, but the build + // itself genuinely needs more RAM. + // - 120 GB / 16 vCPU (current) → the MAX Fargate admits at 16 vCPU (32–120 + // GB in 8 GB steps). If a build OOMs even here, the fix is to cut the + // build's peak parallelism (serialize the mise DAG / cap jest workers), + // not more RAM — there is none. Paired with BUILD_VERIFY_TIMEOUT_S=3600. this.taskDefinition = new ecs.FargateTaskDefinition(this, 'TaskDef', { cpu: 16384, - memoryLimitMiB: 65536, + memoryLimitMiB: 122880, runtimePlatform: { cpuArchitecture: ecs.CpuArchitecture.ARM64, operatingSystemFamily: ecs.OperatingSystemFamily.LINUX, diff --git a/cdk/test/constructs/ecs-agent-cluster.test.ts b/cdk/test/constructs/ecs-agent-cluster.test.ts index aa06b362..6dd85cd3 100644 --- a/cdk/test/constructs/ecs-agent-cluster.test.ts +++ b/cdk/test/constructs/ecs-agent-cluster.test.ts @@ -88,10 +88,10 @@ describe('EcsAgentCluster construct', () => { }); }); - test('creates a Fargate task definition with 16 vCPU and 64 GB (K14: full parallel mise build OOM\'d at 32 GB)', () => { + test('creates a Fargate task definition with 16 vCPU and 120 GB (ABCA-662: full parallel mise build OOM\'d at 64 GB → max Fargate RAM)', () => { baseTemplate.hasResourceProperties('AWS::ECS::TaskDefinition', { Cpu: '16384', - Memory: '65536', + Memory: '122880', RequiresCompatibilities: ['FARGATE'], RuntimePlatform: { CpuArchitecture: 'ARM64', From 9add78474bf00276090f3bfde6e81ac802fd9031 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Thu, 9 Jul 2026 19:06:36 -0400 Subject: [PATCH 10/18] fix(cdk): make ecs-strategy top-of-file import hermetic vs ambient env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline-fallback / no-op tests in the top-of-file describe blocks assume the OPTIONAL vars ECS_PAYLOAD_BUCKET and ECS_PLANNING_TASK_DEFINITION_ARN are ABSENT when the module is first imported (it reads them as module-level constants). A dev shell has neither set, so the suite passed locally — but the REAL ECS agent container HAS ECS_PAYLOAD_BUCKET set (#502 payload bucket), so on ECS the const was truthy and the 'no bucket → inline fallback' + 'no-op' assertions failed: FAIL test/handlers/shared/strategies/ecs-strategy.test.ts ● startSession › sends RunTaskCommand with correct params ... ● deleteEcsPayload without ECS_PAYLOAD_BUCKET › no-ops ... This was the residual fork baseline-build exit-1: 2 failed / 3093 passed, only ever reproducible inside the ECS microVM. Surfaced by the live-streaming build log (the buffered summary had hidden it). Fix: delete both optional vars before the top-of-file import so it's hermetic regardless of the runner's environment; the #502/#299 describe blocks already set them via jest.isolateModules. (cherry picked from commit 7a2bde95bf47b82cee36a5ed282804ae5b25f3e7) (cherry picked from commit 8267c29d918fd211e992262cebdc40b13e2d2383) --- cdk/test/handlers/shared/strategies/ecs-strategy.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts b/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts index 617d2e1f..d3b6a0d0 100644 --- a/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts +++ b/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts @@ -27,6 +27,14 @@ process.env.ECS_TASK_DEFINITION_ARN = TASK_DEF_ARN; process.env.ECS_SUBNETS = 'subnet-aaa,subnet-bbb'; process.env.ECS_SECURITY_GROUP = 'sg-12345'; process.env.ECS_CONTAINER_NAME = 'AgentContainer'; +// The top-of-file import's inline-fallback / no-op tests assume these OPTIONAL +// vars are ABSENT at load time. They are unset in a dev shell but the real ECS +// agent container HAS ECS_PAYLOAD_BUCKET set (#502) — so leaving this to ambient +// env made the build pass locally yet FAIL on ECS ("works local, dies on ECS"). +// The #502 / #299 describe blocks below set these via isolateModules; delete them +// here so the top-of-file import is hermetic regardless of the runner's env. +delete process.env.ECS_PAYLOAD_BUCKET; +delete process.env.ECS_PLANNING_TASK_DEFINITION_ARN; const mockSend = jest.fn(); jest.mock('@aws-sdk/client-ecs', () => ({ From 4acccf35c74d9aadb710dfb3f8fca6c94c844e32 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Thu, 9 Jul 2026 19:29:18 -0400 Subject: [PATCH 11/18] fix(cdk): grant ec2:DescribeAvailabilityZones to ECS agent task role (cdk synth on fresh clone) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CDK-based target repo's build gate runs `cdk synth`. When the stack is wired to a concrete env ({account, region}), CDK does a synth-time availability-zone context lookup (ec2:DescribeAvailabilityZones). A developer box caches the result in the gitignored cdk.context.json so synth is hermetic; the agent clones fresh, so there is no cache and the live lookup fires. The ECS task role lacked the action, so synth failed with: ERROR ...EcsAgentClusterTaskRole... is not authorized to perform: ec2:DescribeAvailabilityZones ... Synthesis finished with errors → a FALSE build-gate failure on code that builds fine everywhere else. Same ECS-parity class as ABCA-488 (GetSecretValue) and F-2 (CreateEvent): a permission the AgentCore path had but the ECS task role didn't. Live-caught on the ABCA fork: baseline `mise run build` passed all 3095 tests then died at `cdk synth -q`, surfaced only by the live-streaming build log. Grant is a read-only describe (Resource:* — EC2 describe actions have no resource-level scoping; IAM5 suppressed, no mutation/data access). +1 regression test pins the statement. (cherry picked from commit 3fc906054c30751a75e274c0e28de010478d6169) (cherry picked from commit 2bf12f4aca505427c299958c47d47d4823135726) --- cdk/src/constructs/ecs-agent-cluster.ts | 18 ++++++++++++++++- cdk/test/constructs/ecs-agent-cluster.test.ts | 20 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/cdk/src/constructs/ecs-agent-cluster.ts b/cdk/src/constructs/ecs-agent-cluster.ts index 0115d74d..994194ee 100644 --- a/cdk/src/constructs/ecs-agent-cluster.ts +++ b/cdk/src/constructs/ecs-agent-cluster.ts @@ -283,6 +283,22 @@ export class EcsAgentCluster extends Construct { resources: bedrockResources, })); + // ECS-parity: a CDK-based target repo's build gate runs `cdk synth`, and a + // stack wired to a concrete env ({account, region}) does a synth-time + // availability-zone context lookup (ec2:DescribeAvailabilityZones). On a + // developer box the gitignored cdk.context.json caches the answer so synth + // is hermetic; the agent clones fresh, so there's no cache and synth fires + // the live lookup. Without this grant the ECS task role hit AccessDenied → + // "Synthesis finished with errors" → a FALSE build-gate failure on code that + // builds fine everywhere else (live-caught on the ABCA fork; same class as + // the ABCA-488 GetSecretValue and F-2 CreateEvent ECS-parity gaps). This is a + // read-only describe with no resource-level scoping in IAM, so Resource:* is + // required (suppressed below); it grants no mutation and no data access. + taskRole.addToPrincipalPolicy(new iam.PolicyStatement({ + actions: ['ec2:DescribeAvailabilityZones'], + resources: ['*'], + })); + // CloudWatch Logs write logGroup.grantWrite(taskRole); @@ -293,7 +309,7 @@ export class EcsAgentCluster extends Construct { NagSuppressions.addResourceSuppressions(this.taskDefinition, [ { id: 'AwsSolutions-IAM5', - reason: 'DynamoDB index/* wildcards from CDK grantReadWriteData (UserConcurrencyTable, and task tables only when no SessionRole is wired); Secrets Manager wildcards from CDK grantRead (GitHub token) and the bgagent-linear-oauth-*/bgagent-jira-oauth-* prefix grant (ABCA-488 — per-workspace channel OAuth tokens are created by the CLI at setup, name unknown at synth, GetSecretValue only); CloudWatch Logs wildcards from CDK grantWrite; S3 object/* wildcard from CDK grantRead on the ECS payload bucket (read-only, scoped to that bucket — #502) and from grantReadWrite on the artifacts bucket (scoped to that bucket — coding/decompose-v1 delivers its plan artifact there, #299). Bedrock InvokeModel is scoped to explicit model/inference-profile ARNs (no wildcard resource).', + reason: 'DynamoDB index/* wildcards from CDK grantReadWriteData (UserConcurrencyTable, and task tables only when no SessionRole is wired); Secrets Manager wildcards from CDK grantRead (GitHub token) and the bgagent-linear-oauth-*/bgagent-jira-oauth-* prefix grant (ABCA-488 — per-workspace channel OAuth tokens are created by the CLI at setup, name unknown at synth, GetSecretValue only); CloudWatch Logs wildcards from CDK grantWrite; S3 object/* wildcard from CDK grantRead on the ECS payload bucket (read-only, scoped to that bucket — #502) and from grantReadWrite on the artifacts bucket (scoped to that bucket — coding/decompose-v1 delivers its plan artifact there, #299). Bedrock InvokeModel is scoped to explicit model/inference-profile ARNs (no wildcard resource). ec2:DescribeAvailabilityZones requires Resource:* (EC2 describe actions have no resource-level scoping) — read-only, no mutation/data access; needed so a CDK target repo\'s `cdk synth` build gate can resolve AZ context on a fresh clone (ECS-parity, no cdk.context.json cache in the container).', }, { id: 'AwsSolutions-ECS2', diff --git a/cdk/test/constructs/ecs-agent-cluster.test.ts b/cdk/test/constructs/ecs-agent-cluster.test.ts index 6dd85cd3..613866ba 100644 --- a/cdk/test/constructs/ecs-agent-cluster.test.ts +++ b/cdk/test/constructs/ecs-agent-cluster.test.ts @@ -195,6 +195,26 @@ describe('EcsAgentCluster construct', () => { expect(serialized).toContain('anthropic.claude-haiku-4-5-20251001-v1:0'); }); + test('task role can DescribeAvailabilityZones so a CDK target repo can `cdk synth` on a fresh clone (ECS-parity)', () => { + // REGRESSION: `mise run build` on a CDK-based target repo runs `cdk synth`, + // and a stack wired to a concrete env does a synth-time AZ context lookup + // (ec2:DescribeAvailabilityZones). A dev box caches the answer in the + // gitignored cdk.context.json; the agent clones fresh (no cache) → the live + // lookup fires. Without this grant the ECS task role hit AccessDenied → + // "Synthesis finished with errors" → a FALSE build-gate failure. Pin the + // read-only describe (Resource:* — EC2 describe has no resource scoping). + const policies = baseTemplate.findResources('AWS::IAM::Policy'); + let azStatement: { Resource: unknown } | undefined; + for (const p of Object.values(policies)) { + for (const s of p.Properties.PolicyDocument.Statement) { + const actions = Array.isArray(s.Action) ? s.Action : [s.Action]; + if (actions.includes('ec2:DescribeAvailabilityZones')) azStatement = s; + } + } + expect(azStatement).toBeDefined(); + expect(azStatement!.Resource).toEqual('*'); + }); + test('bedrockModels context override changes the granted model ARNs (#433)', () => { const template = createStack({ bedrockModels: ['anthropic.claude-opus-4-8'] }).template; const policies = template.findResources('AWS::IAM::Policy'); From 209c66a84bf425f0df1c2ffd9ccd306a23f3d31f Mon Sep 17 00:00:00 2001 From: bgagent Date: Thu, 2 Jul 2026 12:49:11 -0400 Subject: [PATCH 12/18] fix(ecs): grant ECS task role AgentCore Memory access (F-2, ABCA-488-class) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-caught during the fork stress smoke (ABCA-495): the ECS TaskDefTaskRole got AccessDeniedException on bedrock-agentcore:CreateEvent for the AgentMemory resource, so the agent's cross-task learning writes (write_task_episode / write_repo_learnings) silently no-op'd (WARN) on EVERY ECS task — memory never persisted on the fork's ECS-only substrate. Same class as ABCA-488: the AgentCore runtime role gets memory access via agentMemory.grantReadWrite(runtime) in agent.ts, and the orchestrator gets it too, but the ECS task role never did. Fix: EcsAgentCluster gains an optional agentMemory prop; when provided the task role gets agentMemory.grantReadWrite (read actions + CreateEvent), scoped to the memory ARN (synth-verified: no wildcard resource, so the existing IAM5 nag suppression is unaffected). agent.ts passes agentMemory to the ECS cluster alongside the existing memoryId. Omitted in isolated construct tests / memory-less deploys → no grant (asserted). Tests: construct asserts CreateEvent on the task role scoped to MemoryArn when wired, and NO bedrock-agentcore grant when unwired. Full cdk build green (2883). (cherry picked from commit c23d49fbead30ed52860e3975815e3643b22ade0) (cherry picked from commit 44bb71ef5e98fe0f64e51510b077b4718ade210e) --- cdk/src/constructs/ecs-agent-cluster.ts | 24 ++++++++ cdk/test/constructs/ecs-agent-cluster.test.ts | 59 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/cdk/src/constructs/ecs-agent-cluster.ts b/cdk/src/constructs/ecs-agent-cluster.ts index 994194ee..295e6f37 100644 --- a/cdk/src/constructs/ecs-agent-cluster.ts +++ b/cdk/src/constructs/ecs-agent-cluster.ts @@ -28,6 +28,7 @@ import * as s3 from 'aws-cdk-lib/aws-s3'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; import { NagSuppressions } from 'cdk-nag'; import { Construct } from 'constructs'; +import { AgentMemory } from './agent-memory'; import { AgentSessionRole } from './agent-session-role'; import { resolveBedrockModelIds } from './bedrock-models'; @@ -74,6 +75,18 @@ export interface EcsAgentClusterProps { * retains the legacy direct grants. */ readonly agentSessionRole?: AgentSessionRole; + + /** + * AgentCore Memory for cross-task learning (F-2 / ABCA-488-class parity). When + * provided, the ECS task role is granted read+write on it so the agent's + * memory writes (write_task_episode / write_repo_learnings → + * ``bedrock-agentcore:CreateEvent``) succeed on the ECS substrate. The + * AgentCore runtime role already gets this via ``agentMemory.grantReadWrite`` + * in agent.ts; without the same grant here, memory writes hit AccessDenied and + * silently no-op on ECS (WARN), so learning never persists on an ECS-only + * deployment. Omitted in isolated construct tests / memory-less deployments. + */ + readonly agentMemory?: AgentMemory; } /** HTTPS port — the only egress allowed from the agent task ENIs. */ @@ -222,6 +235,17 @@ export class EcsAgentCluster extends Construct { props.artifactsBucket.grantReadWrite(taskRole); } + // F-2 (ABCA-488-class parity): grant the task role read+write on the + // AgentCore Memory so the agent's cross-task learning writes + // (write_task_episode / write_repo_learnings → bedrock-agentcore:CreateEvent) + // succeed on ECS. The AgentCore runtime role gets this via + // agentMemory.grantReadWrite(runtime) in agent.ts; without the same grant + // here the writes hit AccessDenied and silently no-op (WARN) on the ECS + // substrate, so learning never persists on an ECS-only deployment. + if (props.agentMemory) { + props.agentMemory.grantReadWrite(taskRole); + } + // ABCA-488: per-workspace Linear/Jira OAuth tokens live in Secrets Manager // under `bgagent-linear-oauth-*` (written by the CLI at setup). For a // Linear/Jira-channel task the agent resolves that token at startup diff --git a/cdk/test/constructs/ecs-agent-cluster.test.ts b/cdk/test/constructs/ecs-agent-cluster.test.ts index 613866ba..4ac63b45 100644 --- a/cdk/test/constructs/ecs-agent-cluster.test.ts +++ b/cdk/test/constructs/ecs-agent-cluster.test.ts @@ -26,6 +26,7 @@ import * as ecr_assets from 'aws-cdk-lib/aws-ecr-assets'; import * as iam from 'aws-cdk-lib/aws-iam'; import * as s3 from 'aws-cdk-lib/aws-s3'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; +import { AgentMemory } from '../../src/constructs/agent-memory'; import { AgentSessionRole } from '../../src/constructs/agent-session-role'; import { EcsAgentCluster } from '../../src/constructs/ecs-agent-cluster'; @@ -174,6 +175,64 @@ describe('EcsAgentCluster construct', () => { expect(hasLinearOauthGrant).toBe(true); }); + test('task role gets bedrock-agentcore:CreateEvent on the AgentMemory when wired (F-2 / ABCA-488-class)', () => { + // REGRESSION: the agent's cross-task learning writes (write_task_episode / + // write_repo_learnings) call bedrock-agentcore:CreateEvent on the AgentCore + // Memory. The runtime role gets this via agentMemory.grantReadWrite; the ECS + // task role did NOT, so writes hit AccessDenied and silently no-op'd (WARN) + // on the ECS substrate — learning never persisted on an ECS-only deploy. + // Build a stack WITH an AgentMemory and assert the CreateEvent grant exists, + // scoped to the memory ARN (not a wildcard). + const app = new App(); + const stack = new Stack(app, 'EcsMemStack'); + const vpc = new ec2.Vpc(stack, 'Vpc', { maxAzs: 2 }); + const agentImageAsset = new ecr_assets.DockerImageAsset(stack, 'AgentImage', { + directory: path.join(__dirname, '..', '..', '..', 'agent'), + }); + const mk = (id: string) => + new dynamodb.Table(stack, id, { partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING } }); + const userConcurrencyTable = new dynamodb.Table(stack, 'UserConcurrencyTable', { + partitionKey: { name: 'user_id', type: dynamodb.AttributeType.STRING }, + }); + const agentMemory = new AgentMemory(stack, 'AgentMemory'); + new EcsAgentCluster(stack, 'EcsAgentCluster', { + vpc, + agentImageAsset, + taskTable: mk('TaskTable'), + taskEventsTable: mk('TaskEventsTable'), + userConcurrencyTable, + githubTokenSecret: new secretsmanager.Secret(stack, 'GitHubTokenSecret'), + agentMemory, + }); + const template = Template.fromStack(stack); + const policies = template.findResources('AWS::IAM::Policy'); + let hasCreateEvent = false; + for (const [id, p] of Object.entries(policies)) { + if (!id.includes('TaskDefTaskRole')) continue; + for (const s of p.Properties.PolicyDocument.Statement) { + const actions = Array.isArray(s.Action) ? s.Action : [s.Action]; + if (actions.includes('bedrock-agentcore:CreateEvent')) { + hasCreateEvent = true; + // resource must reference the memory ARN, not a bare wildcard + expect(JSON.stringify(s.Resource)).toContain('MemoryArn'); + expect(s.Resource).not.toEqual('*'); + } + } + } + expect(hasCreateEvent).toBe(true); + }); + + test('task role has NO bedrock-agentcore grant when no AgentMemory is wired (isolated default)', () => { + const policies = baseTemplate.findResources('AWS::IAM::Policy'); + for (const [id, p] of Object.entries(policies)) { + if (!id.includes('TaskDefTaskRole')) continue; + for (const s of p.Properties.PolicyDocument.Statement) { + const actions = Array.isArray(s.Action) ? s.Action : [s.Action]; + expect(actions.some((a: string) => a.startsWith('bedrock-agentcore:'))).toBe(false); + } + } + }); + test('task role Bedrock InvokeModel is scoped to explicit model/inference-profile ARNs (no wildcard)', () => { const policies = baseTemplate.findResources('AWS::IAM::Policy'); let bedrockStatement: { Resource: unknown } | undefined; From a5550eb01ba439bed89c7658e3ef195452f3d594 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Fri, 10 Jul 2026 18:46:38 -0400 Subject: [PATCH 13/18] docs(bootstrap): correct the ECS ComputeTypes enable instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The //cdk:bootstrap task hint and DEPLOYMENT_ROLES.md both said to enable the ECS compute backend with 'mise //cdk:bootstrap -- --context ComputeTypes=agentcore,ecs'. That does NOT work: ComputeTypes is a CloudFormation *template parameter*, and this CDK version's 'cdk bootstrap' has no way to set one — there's no --parameters flag, and --context sets CDK construct context, not a CFN parameter. So the ECS policy (IaCRole-ABCA-Compute-ECS, conditional on ComputeTypes containing 'ecs') was never attached, and a --context compute_type=ecs deploy then failed with AccessDenied on ecs:* — live-caught deploying the substrate to dev. Correct it to the mechanism that actually works: bootstrap normally, then set the parameter directly on the CDKToolkit stack via 'aws cloudformation update-stack ... ParameterKey=ComputeTypes,ParameterValue=agentcore,ecs' (with a describe-stacks verify). Fixed in the mise task description + comment and both DEPLOYMENT_ROLES.md occurrences; Starlight mirror regenerated. Drift gate green. (cherry picked from commit a46683e23a685de20dea303cd5f702789afc849a) --- cdk/mise.toml | 11 ++++++++++- docs/design/DEPLOYMENT_ROLES.md | 15 ++++++++++++--- .../content/docs/architecture/Deployment-roles.md | 15 ++++++++++++--- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/cdk/mise.toml b/cdk/mise.toml index 60332012..8847487d 100644 --- a/cdk/mise.toml +++ b/cdk/mise.toml @@ -40,8 +40,17 @@ run = ["mkdir -p $TMPDIR", "yarn synth:quiet"] description = "cdk deploy (pass args after --)" run = "npx cdk deploy" +# Bootstraps with ComputeTypes=agentcore (the template default). To ALSO enable the +# ECS compute backend you must set the ComputeTypes CFN *parameter* — `cdk bootstrap` +# has no way to pass template parameters (no --parameters flag; --context sets CDK +# construct context, NOT a CFN parameter), so run this task once, then set the +# parameter directly on the CDKToolkit stack: +# aws cloudformation update-stack --stack-name CDKToolkit --use-previous-template \ +# --capabilities CAPABILITY_NAMED_IAM \ +# --parameters ParameterKey=ComputeTypes,ParameterValue=agentcore\,ecs +# # verify: aws cloudformation describe-stacks --stack-name CDKToolkit --query 'Stacks[0].Parameters' [tasks.bootstrap] -description = "Bootstrap CDK with least-privilege policies (pass -- --context ComputeTypes=agentcore,ecs for ECS)" +description = "Bootstrap CDK with least-privilege policies (ComputeTypes=agentcore; for ECS set the ComputeTypes CFN param on CDKToolkit — see comment above the task)" depends = [":install", ":bootstrap:generate"] run = "npx cdk bootstrap --template bootstrap/bootstrap-template.yaml" diff --git a/docs/design/DEPLOYMENT_ROLES.md b/docs/design/DEPLOYMENT_ROLES.md index deff7497..9b3e7f81 100644 --- a/docs/design/DEPLOYMENT_ROLES.md +++ b/docs/design/DEPLOYMENT_ROLES.md @@ -33,9 +33,18 @@ These policies are not created or attached manually. The repository generates th ```bash # Regenerate artifacts (policies JSON + template YAML) and bootstrap. -# Default ComputeTypes is "agentcore"; pass ECS via context to also include Compute-ECS: +# ComputeTypes defaults to "agentcore". MISE_EXPERIMENTAL=1 mise //cdk:bootstrap -MISE_EXPERIMENTAL=1 mise //cdk:bootstrap -- --context ComputeTypes=agentcore,ecs + +# To ALSO enable the ECS compute backend (attach IaCRole-ABCA-Compute-ECS), set the +# ComputeTypes CFN *parameter*. `cdk bootstrap` cannot pass template parameters — it +# has no --parameters flag, and --context sets CDK construct context, not a CFN +# parameter — so bootstrap first (above), then update the parameter on CDKToolkit: +aws cloudformation update-stack --stack-name CDKToolkit --use-previous-template \ + --capabilities CAPABILITY_NAMED_IAM \ + --parameters ParameterKey=ComputeTypes,ParameterValue=agentcore\,ecs +# verify it took: +aws cloudformation describe-stacks --stack-name CDKToolkit --query 'Stacks[0].Parameters' ``` Under the hood, `mise //cdk:bootstrap` runs `npx cdk bootstrap --template bootstrap/bootstrap-template.yaml` (see `cdk/mise.toml`). The generated template defines five inline `AWS::IAM::ManagedPolicy` resources that **replace** the default `AdministratorAccess` on the CloudFormation execution role; the `IaCRole-ABCA-Compute-ECS` policy is conditional on the `ComputeTypes` parameter including `ecs`. The policy sources are `cdk/src/bootstrap/policies/{infrastructure,application,observability,compute-agentcore,compute-ecs}.ts`, compiled to `cdk/bootstrap/policies/*.json` by `cdk/scripts/generate-bootstrap-artifacts.ts`. @@ -701,7 +710,7 @@ Bedrock AgentCore runtime/memory operations — a single statement granting `bed ### IaCRole-ABCA-Compute-ECS -When the ECS Fargate compute backend is enabled (bootstrap with `--context ComputeTypes=agentcore,ecs`), the generated template conditionally attaches this policy to the CloudFormation execution role. It is a standalone managed policy, not an addition to `IaCRole-ABCA-Application`. +When the ECS Fargate compute backend is enabled (set the `ComputeTypes` CFN parameter to `agentcore,ecs` on the `CDKToolkit` stack — see the bootstrap snippet near the top of this doc), the generated template conditionally attaches this policy to the CloudFormation execution role. It is a standalone managed policy, not an addition to `IaCRole-ABCA-Application`. ```json { diff --git a/docs/src/content/docs/architecture/Deployment-roles.md b/docs/src/content/docs/architecture/Deployment-roles.md index 0155c36d..ac0d44cb 100644 --- a/docs/src/content/docs/architecture/Deployment-roles.md +++ b/docs/src/content/docs/architecture/Deployment-roles.md @@ -37,9 +37,18 @@ These policies are not created or attached manually. The repository generates th ```bash # Regenerate artifacts (policies JSON + template YAML) and bootstrap. -# Default ComputeTypes is "agentcore"; pass ECS via context to also include Compute-ECS: +# ComputeTypes defaults to "agentcore". MISE_EXPERIMENTAL=1 mise //cdk:bootstrap -MISE_EXPERIMENTAL=1 mise //cdk:bootstrap -- --context ComputeTypes=agentcore,ecs + +# To ALSO enable the ECS compute backend (attach IaCRole-ABCA-Compute-ECS), set the +# ComputeTypes CFN *parameter*. `cdk bootstrap` cannot pass template parameters — it +# has no --parameters flag, and --context sets CDK construct context, not a CFN +# parameter — so bootstrap first (above), then update the parameter on CDKToolkit: +aws cloudformation update-stack --stack-name CDKToolkit --use-previous-template \ + --capabilities CAPABILITY_NAMED_IAM \ + --parameters ParameterKey=ComputeTypes,ParameterValue=agentcore\,ecs +# verify it took: +aws cloudformation describe-stacks --stack-name CDKToolkit --query 'Stacks[0].Parameters' ``` Under the hood, `mise //cdk:bootstrap` runs `npx cdk bootstrap --template bootstrap/bootstrap-template.yaml` (see `cdk/mise.toml`). The generated template defines five inline `AWS::IAM::ManagedPolicy` resources that **replace** the default `AdministratorAccess` on the CloudFormation execution role; the `IaCRole-ABCA-Compute-ECS` policy is conditional on the `ComputeTypes` parameter including `ecs`. The policy sources are `cdk/src/bootstrap/policies/{infrastructure,application,observability,compute-agentcore,compute-ecs}.ts`, compiled to `cdk/bootstrap/policies/*.json` by `cdk/scripts/generate-bootstrap-artifacts.ts`. @@ -705,7 +714,7 @@ Bedrock AgentCore runtime/memory operations — a single statement granting `bed ### IaCRole-ABCA-Compute-ECS -When the ECS Fargate compute backend is enabled (bootstrap with `--context ComputeTypes=agentcore,ecs`), the generated template conditionally attaches this policy to the CloudFormation execution role. It is a standalone managed policy, not an addition to `IaCRole-ABCA-Application`. +When the ECS Fargate compute backend is enabled (set the `ComputeTypes` CFN parameter to `agentcore,ecs` on the `CDKToolkit` stack — see the bootstrap snippet near the top of this doc), the generated template conditionally attaches this policy to the CloudFormation execution role. It is a standalone managed policy, not an addition to `IaCRole-ABCA-Application`. ```json { From ce93b626f40311ab5130130d738e1834cb0cc604 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Tue, 14 Jul 2026 15:31:31 -0400 Subject: [PATCH 14/18] fix(ecs): remove dead ECS_PAYLOAD_OBJECT_KEY_PREFIX export (review B1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `export const ECS_PAYLOAD_OBJECT_KEY_PREFIX = ''` constant was referenced nowhere — ecsPayloadKey() (ecs-strategy.ts) builds `/payload.json` independently and already documents that layout in its own docstring. The dead export was a "prefix" whose value was '' with a docstring describing a key layout it didn't produce, and it raised the knip dead-code count, tripping the ratchet inside `mise run build`. Delete it; ecsPayloadKey remains the single source of truth for the key layout. --- cdk/src/constructs/ecs-payload-bucket.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/cdk/src/constructs/ecs-payload-bucket.ts b/cdk/src/constructs/ecs-payload-bucket.ts index 62a8c64c..87dc4755 100644 --- a/cdk/src/constructs/ecs-payload-bucket.ts +++ b/cdk/src/constructs/ecs-payload-bucket.ts @@ -32,13 +32,6 @@ import { Construct } from 'constructs'; */ export const ECS_PAYLOAD_TTL_DAYS = 1; -/** - * Object-key prefix for ECS task payloads. Key layout: - * ``/payload.json``. Each task writes a single object under its own - * task-id prefix; the orchestrator deletes it on terminal. - */ -export const ECS_PAYLOAD_OBJECT_KEY_PREFIX = ''; - /** * Properties for the EcsPayloadBucket construct. */ From 8644f9c8bb281837be4d2d07261c44c8a1b1dca1 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Tue, 14 Jul 2026 15:40:42 -0400 Subject: [PATCH 15/18] =?UTF-8?q?docs+feat:=20address=20review=20nits=20N1?= =?UTF-8?q?=E2=80=93N4=20on=20the=20ECS=20substrate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups for #596 (fix itself unchanged; B1 dead-export fixed in the base PR #503): - N1: correct the stale "64 GB / 16 vCPU" comment in agent.ts — the task is larger (64 GB was itself OOM-killed per the construct's sizing history). Defer the exact figure to EcsAgentCluster rather than duplicate/drift it. - N2: reword the memory-grant + oauth-grant comments — an AccessDenied on those paths is LOGGED (memory.py treats it as an infra failure; config.py's token resolver logs it), not "silently" no-op'd. Describe the user-visible effect (no persisted learning / no 👀→✅ reaction) rather than pin a log level that drifts. - N3: the ec2:DescribeAvailabilityZones comment already frames it correctly as a FALSE build-gate failure (not a WARN no-op) — no change needed; noted here for completeness. - N4: run_task_from_payload now emits a WARN when it drops a KNOWN orchestrator key (build_command / merge_branches / base_branch / github_token_secret_arn) that run_task doesn't accept — expected today (consumed elsewhere / not yet wired), but makes a future "wired one side, forgot the other" no-op visible instead of silent (the ABCA-487 class). Foreign keys still drop quietly. + 2 tests (warns on a known dropped key; stays quiet on a foreign key). Full cdk build green (2286) + full agent suite green (1194); eslint/ruff clean. --- agent/src/pipeline.py | 25 ++++++++++++++++++++++- agent/tests/test_run_task_from_payload.py | 19 +++++++++++++++++ cdk/src/constructs/ecs-agent-cluster.ts | 13 +++++++----- cdk/src/stacks/agent.ts | 5 +++-- 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/agent/src/pipeline.py b/agent/src/pipeline.py index 191fd7ee..458394ef 100644 --- a/agent/src/pipeline.py +++ b/agent/src/pipeline.py @@ -1275,6 +1275,21 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: #: shadow it). Any payload key not in this set is ignored, never passed through. _RUN_TASK_PARAMS = frozenset(inspect.signature(run_task).parameters) +#: Orchestrator payload keys we KNOW about that ``run_task`` does not (yet) +#: accept as a parameter. Dropping one of these is expected today (e.g. +#: ``base_branch`` is consumed via ``hydrated_context``, not a run_task kwarg; +#: ``github_token_secret_arn`` is resolved before this call), but a key that +#: shows up here AND is silently dropped is exactly the "wired one side of an +#: orchestrator→agent field, forgot the other" no-op that ABCA-487 was — so we +#: WARN when we drop one, making a future contract gap visible instead of silent. +#: Keys not in this set (genuinely foreign) are dropped quietly as before. +_KNOWN_ORCHESTRATOR_KEYS = frozenset({ + "build_command", + "merge_branches", + "base_branch", + "github_token_secret_arn", +}) + def run_task_from_payload(payload: dict) -> dict: """Invoke :func:`run_task` from a full orchestrator payload dict. @@ -1301,7 +1316,15 @@ def run_task_from_payload(payload: dict) -> dict: for key, value in (payload or {}).items(): target = _PAYLOAD_KEY_ALIASES.get(key, key) if target not in _RUN_TASK_PARAMS: - continue # not a run_task parameter — ignore (e.g. github_token_secret_arn) + # Not a run_task parameter — ignore. A KNOWN orchestrator key being + # dropped is expected today but worth a breadcrumb: if run_task ever + # grows a matching param, this WARN is where a "forgot to wire it + # through" no-op surfaces (N4 / ABCA-487 class). Foreign keys are + # dropped quietly. + if key in _KNOWN_ORCHESTRATOR_KEYS and value is not None: + log("WARN", f"run_task_from_payload: dropping known orchestrator key '{key}' " + f"(not a run_task parameter) — consumed elsewhere or not yet wired") + continue if value is None: continue # let run_task's default apply if target in _PAYLOAD_STR_KEYS: diff --git a/agent/tests/test_run_task_from_payload.py b/agent/tests/test_run_task_from_payload.py index e9dca722..38c29830 100644 --- a/agent/tests/test_run_task_from_payload.py +++ b/agent/tests/test_run_task_from_payload.py @@ -142,3 +142,22 @@ def test_every_forwarded_key_is_a_real_run_task_param(self): } ) assert set(seen).issubset(accepted) + + def test_warns_when_dropping_a_known_orchestrator_key(self): + # N4: a KNOWN orchestrator key that run_task doesn't accept is dropped + # (expected today) but logged, so a future "wired one side, forgot the + # other" contract gap (the ABCA-487 class) is visible, not silent. + logs: list[tuple[str, str]] = [] + with patch("pipeline.log", side_effect=lambda level, msg, **kw: logs.append((level, msg))): + _capture({"build_command": "mise run build", "repo_url": "r"}) + assert [m for level, m in logs if level == "WARN" and "build_command" in m], ( + "expected a WARN when dropping the known orchestrator key build_command" + ) + + def test_does_NOT_warn_when_dropping_a_foreign_key(self): + # A genuinely-foreign key (not a known orchestrator field) is dropped + # quietly — no log noise for keys we never expected to forward. + logs: list[tuple[str, str]] = [] + with patch("pipeline.log", side_effect=lambda level, msg, **kw: logs.append((level, msg))): + _capture({"some_future_unrelated_key": "v", "repo_url": "r"}) + assert not [m for level, m in logs if "some_future_unrelated_key" in m] diff --git a/cdk/src/constructs/ecs-agent-cluster.ts b/cdk/src/constructs/ecs-agent-cluster.ts index 295e6f37..7e671879 100644 --- a/cdk/src/constructs/ecs-agent-cluster.ts +++ b/cdk/src/constructs/ecs-agent-cluster.ts @@ -83,8 +83,9 @@ export interface EcsAgentClusterProps { * ``bedrock-agentcore:CreateEvent``) succeed on the ECS substrate. The * AgentCore runtime role already gets this via ``agentMemory.grantReadWrite`` * in agent.ts; without the same grant here, memory writes hit AccessDenied and - * silently no-op on ECS (WARN), so learning never persists on an ECS-only - * deployment. Omitted in isolated construct tests / memory-less deployments. + * no-op on ECS (logged, non-fatal — memory.py treats an AccessDenied as an + * infra failure), so learning never persists on an ECS-only deployment. + * Omitted in isolated construct tests / memory-less deployments. */ readonly agentMemory?: AgentMemory; } @@ -240,8 +241,8 @@ export class EcsAgentCluster extends Construct { // (write_task_episode / write_repo_learnings → bedrock-agentcore:CreateEvent) // succeed on ECS. The AgentCore runtime role gets this via // agentMemory.grantReadWrite(runtime) in agent.ts; without the same grant - // here the writes hit AccessDenied and silently no-op (WARN) on the ECS - // substrate, so learning never persists on an ECS-only deployment. + // here the writes hit AccessDenied and no-op on the ECS substrate (logged, + // non-fatal), so learning never persists on an ECS-only deployment. if (props.agentMemory) { props.agentMemory.grantReadWrite(taskRole); } @@ -253,7 +254,9 @@ export class EcsAgentCluster extends Construct { // 👀→✅ reaction and drive the channel MCP. The AgentCore runtime role + // orchestrator/fanout/screenshot roles all have this prefix grant; the ECS // task role did NOT, so on ECS the token fetch hit AccessDenied and - // reactions/MCP silently no-op'd (ECS-parity gap, live-caught on ABCA-488). + // reactions/MCP no-op'd — logged by config.py's token resolver, not silent, + // but the channel effect (no 👀→✅, no MCP) is invisible to the user + // (ECS-parity gap, live-caught on ABCA-488). // GetSecretValue only — the container reads the token; the orchestrator owns // refresh/PutSecretValue. taskRole.addToPrincipalPolicy(new iam.PolicyStatement({ diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index 69f2bdd0..d57f73f1 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -579,8 +579,9 @@ export class AgentStack extends Stack { // --- ECS Fargate compute backend (CONTEXT-GATED) --- // K12 (2026-06-29): AgentCore's fixed microVM envelope OOM-kills heavy // CI-parity builds (ABCA's own ~2800-test `mise run build`). ECS Fargate - // gives a tunable 64 GB / 16 vCPU task (see EcsAgentCluster) for repos that - // set ``compute_type: 'ecs'``. GATED on the ``compute_type`` deploy context + // gives a bigger, tunable task (see EcsAgentCluster for the exact vCPU/memory + // sizing + its OOM history — 64 GB was itself OOM-killed, so it runs larger) + // for repos that set ``compute_type: 'ecs'``. GATED on the ``compute_type`` deploy context // (default 'agentcore') — ECS resources only synthesize when you deploy with // ``--context compute_type=ecs``, so the default synth (and the // bootstrap-coverage test that synths with default context) stays From d258e7649fa11abd9ac09dc6eada5f2e432f568d Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Tue, 14 Jul 2026 16:06:16 -0400 Subject: [PATCH 16/18] style: ruff-format the N4 payload-key block (fix build-mutation failure) CI's "Fail build on mutation" tripped: I ran `ruff check` (passed) but not `ruff format`, so the `_KNOWN_ORCHESTRATOR_KEYS = frozenset({...})` set literal and the two-line `log(...)` call weren't in ruff's canonical multi-line form. `ruff format` normalizes them; no behavior change (12 payload tests still pass). --- agent/src/pipeline.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/agent/src/pipeline.py b/agent/src/pipeline.py index d30ee1ec..6250a0cc 100644 --- a/agent/src/pipeline.py +++ b/agent/src/pipeline.py @@ -1280,12 +1280,14 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: #: orchestrator→agent field, forgot the other" no-op that ABCA-487 was — so we #: WARN when we drop one, making a future contract gap visible instead of silent. #: Keys not in this set (genuinely foreign) are dropped quietly as before. -_KNOWN_ORCHESTRATOR_KEYS = frozenset({ - "build_command", - "merge_branches", - "base_branch", - "github_token_secret_arn", -}) +_KNOWN_ORCHESTRATOR_KEYS = frozenset( + { + "build_command", + "merge_branches", + "base_branch", + "github_token_secret_arn", + } +) def run_task_from_payload(payload: dict) -> dict: @@ -1319,8 +1321,11 @@ def run_task_from_payload(payload: dict) -> dict: # through" no-op surfaces (N4 / ABCA-487 class). Foreign keys are # dropped quietly. if key in _KNOWN_ORCHESTRATOR_KEYS and value is not None: - log("WARN", f"run_task_from_payload: dropping known orchestrator key '{key}' " - f"(not a run_task parameter) — consumed elsewhere or not yet wired") + log( + "WARN", + f"run_task_from_payload: dropping known orchestrator key '{key}' " + f"(not a run_task parameter) — consumed elsewhere or not yet wired", + ) continue if value is None: continue # let run_task's default apply From ca8af9a0cab3760a5a07accbc709e039e973c4de Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Tue, 14 Jul 2026 17:16:20 -0400 Subject: [PATCH 17/18] fix+docs: WARN on dropped task_started_at (HITL parity) + defensive max_turns + comment nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on the #596 re-review (code was already approved-quality; these are the new non-blocking finding + 3 nits): - task_started_at HITL parity: AgentCore's server.py exports task_started_at as TASK_STARTED_AT, which hooks._remaining_maxlifetime_s() uses to clip the Cedar HITL approval-gate maxLifetime. The ECS boot path bypasses server.py and never sets it, so run_task_from_payload dropped it SILENTLY — a fail-open AgentCore↔ECS HITL divergence. Added task_started_at to _KNOWN_ORCHESTRATOR_KEYS so the drop now WARNs (surfaces the gap). Fully wiring TASK_STARTED_AT into the ECS containerEnv is a tracked follow-up; this makes the divergence visible today. - max_turns defensive coercion: a malformed max_turns raised ValueError mid-boot while every other field defaults defensively. Now drops it (run_task default applies) with a WARN, matching the surrounding style. - Nit: artifactsBucket JSDoc no longer claims this bucket is the `--trace` upload target (it wires ARTIFACTS_BUCKET_NAME only; the trace uploader reads TRACE_ARTIFACTS_BUCKET_NAME, unset here — noted as a separate ECS-parity gap). - Nit: dropped `2>/dev/null` from the Dockerfile corepack prepare so the prepare-failed diagnostic isn't hidden (the `|| corepack enable` fallback still guarantees resolvable shims — no regression to the exit-127 inert gate). Tests: +3 (task_started_at WARN, malformed-max_turns dropped-not-raised, valid max_turns still coerced). Full agent suite green (1195); cdk tsc + construct tests green; ruff format + eslint clean. --- agent/Dockerfile | 2 +- agent/src/pipeline.py | 17 ++++++++++++++++- agent/tests/test_run_task_from_payload.py | 23 +++++++++++++++++++++++ cdk/src/constructs/ecs-agent-cluster.ts | 17 +++++++++++------ 4 files changed, 51 insertions(+), 8 deletions(-) diff --git a/agent/Dockerfile b/agent/Dockerfile index bf6723ad..ed017639 100644 --- a/agent/Dockerfile +++ b/agent/Dockerfile @@ -51,7 +51,7 @@ RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && \ # we never actually built (live-caught 2026-06-29 dogfooding ABCA-on-ABCA: the # agent had to hand-build a ``~/bin/yarn`` shim every run). Corepack ships with # Node 24; ``enable`` installs the yarn/pnpm shims onto PATH. -RUN corepack enable && corepack prepare yarn@stable --activate 2>/dev/null || corepack enable +RUN corepack enable && corepack prepare yarn@stable --activate || corepack enable # Install Claude Code CLI (the Python SDK requires this binary) # Then update known vulnerable transitive packages where fixed versions exist. diff --git a/agent/src/pipeline.py b/agent/src/pipeline.py index 6250a0cc..d9d36f0d 100644 --- a/agent/src/pipeline.py +++ b/agent/src/pipeline.py @@ -1286,6 +1286,14 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: "merge_branches", "base_branch", "github_token_secret_arn", + # AgentCore's server.py exports task_started_at as TASK_STARTED_AT, which + # hooks._remaining_maxlifetime_s() uses to clip the Cedar HITL approval-gate + # maxLifetime. The ECS boot path bypasses server.py and does not (yet) set + # that env, so this key is dropped here — a silent AgentCore↔ECS HITL + # divergence (fail-open: the clip returns None, gate uses the task default). + # Listing it makes the drop WARN so the parity gap is visible until the ECS + # strategy sets TASK_STARTED_AT in containerEnv (tracked as a follow-up). + "task_started_at", } ) @@ -1332,7 +1340,14 @@ def run_task_from_payload(payload: dict) -> dict: if target in _PAYLOAD_STR_KEYS: value = str(value) elif target == "max_turns": - value = int(value) + # Defensive, matching how every other field is handled: a malformed + # max_turns must not crash the whole boot — drop it and let run_task's + # default apply (with a breadcrumb) rather than raise a ValueError. + try: + value = int(value) + except (TypeError, ValueError): + log("WARN", f"run_task_from_payload: ignoring non-integer max_turns {value!r}") + continue kwargs[target] = value kwargs.setdefault("aws_region", os.environ.get("AWS_REGION", "")) diff --git a/agent/tests/test_run_task_from_payload.py b/agent/tests/test_run_task_from_payload.py index 38c29830..d6dbabac 100644 --- a/agent/tests/test_run_task_from_payload.py +++ b/agent/tests/test_run_task_from_payload.py @@ -161,3 +161,26 @@ def test_does_NOT_warn_when_dropping_a_foreign_key(self): with patch("pipeline.log", side_effect=lambda level, msg, **kw: logs.append((level, msg))): _capture({"some_future_unrelated_key": "v", "repo_url": "r"}) assert not [m for level, m in logs if "some_future_unrelated_key" in m] + + def test_warns_when_dropping_task_started_at_HITL_parity(self): + # task_started_at drives the AgentCore HITL maxLifetime clip via + # TASK_STARTED_AT; the ECS path doesn't set it yet, so its drop must WARN + # (surface the AgentCore↔ECS parity gap, not silently fail-open). + logs: list[tuple[str, str]] = [] + with patch("pipeline.log", side_effect=lambda level, msg, **kw: logs.append((level, msg))): + _capture({"task_started_at": "2026-07-14T00:00:00Z", "repo_url": "r"}) + assert [m for level, m in logs if level == "WARN" and "task_started_at" in m] + + def test_malformed_max_turns_is_dropped_not_raised(self): + # A non-integer max_turns must not crash the boot — it's dropped (run_task + # default applies) with a WARN, matching how every other field defaults. + logs: list[tuple[str, str]] = [] + with patch("pipeline.log", side_effect=lambda level, msg, **kw: logs.append((level, msg))): + seen = _capture({"repo_url": "r", "max_turns": "not-a-number"}) + assert "max_turns" not in seen + assert [m for level, m in logs if level == "WARN" and "max_turns" in m] + + def test_valid_max_turns_still_coerced_to_int(self): + seen = _capture({"repo_url": "r", "max_turns": "50"}) + assert seen["max_turns"] == 50 + assert isinstance(seen["max_turns"], int) diff --git a/cdk/src/constructs/ecs-agent-cluster.ts b/cdk/src/constructs/ecs-agent-cluster.ts index 7e671879..67fcfc03 100644 --- a/cdk/src/constructs/ecs-agent-cluster.ts +++ b/cdk/src/constructs/ecs-agent-cluster.ts @@ -55,12 +55,17 @@ export interface EcsAgentClusterProps { /** * Artifacts bucket for repo-bound artifact workflows (#299 coding/decompose-v1 - * emits its plan JSON here via ``deliver_artifact``; also the ``--trace`` - * upload target). The AgentCore runtime gets ``ARTIFACTS_BUCKET_NAME`` in its - * env; the ECS task needs the SAME env + read/write grant or an artifact - * workflow fails at delivery with "ARTIFACTS_BUCKET_NAME is not configured" - * (live-caught: a :decompose on an ecs-configured repo). Read/WRITE because the - * container DELIVERS the artifact (unlike the read-only payload bucket). + * emits its plan JSON here via ``deliver_artifact``). The AgentCore runtime + * gets ``ARTIFACTS_BUCKET_NAME`` in its env; the ECS task needs the SAME env + + * read/write grant or an artifact workflow fails at delivery with + * "ARTIFACTS_BUCKET_NAME is not configured" (live-caught: a :decompose on an + * ecs-configured repo). Read/WRITE because the container DELIVERS the artifact + * (unlike the read-only payload bucket). + * + * NOTE: this wires only ``ARTIFACTS_BUCKET_NAME`` (artifact delivery). It does + * NOT set ``TRACE_ARTIFACTS_BUCKET_NAME`` (telemetry.py reads that for the + * ``--trace`` upload), so ``--trace`` silently skips on ECS today — a separate + * ECS-parity gap, not wired here. * Omitted in isolated construct tests → no env/grant. */ readonly artifactsBucket?: s3.IBucket; From 68c97354a4c3cedb6ab83b831f9b1514a1255103 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 15 Jul 2026 18:19:48 -0400 Subject: [PATCH 18/18] =?UTF-8?q?fix(ecs):=20address=20#596=20review=20?= =?UTF-8?q?=E2=80=94=20drop=20over-privileged=20artifacts=20grant=20+=20ni?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1 (least-privilege regression): remove props.artifactsBucket.grantReadWrite from the ECS task role. coding/decompose-v1 delivers its plan via the assumed SessionRole (deliverers.py -> tenant_client), scoped to artifacts/${task_id}/*, exactly like the AgentCore runtime (whose task role likewise has NO direct artifacts grant). The whole-bucket grant over-privileged the untrusted-code role and broke cross-task isolation (a task could read/clobber other tasks' artifacts//, traces/, attachments/). Task role keeps only the ARTIFACTS_BUCKET_NAME env. Correct the inverted 'parity' comment and drop the now-stale artifacts clause from the cdk-nag IAM5 reason. Test: flip 'grants READ+WRITE on artifacts' → asserts the task role has NO S3 Put/Delete action at all (the read-only #502 payload GetObject*/List* grant is not flagged). N4: add lint_command to _KNOWN_ORCHESTRATOR_KEYS (sibling of build_command) so a future 'wired build_command, forgot lint_command' contract gap WARNs instead of dropping silently. (N1/N2/N3 comment nits were already addressed on the branch head; B1 dead-const ECS_PAYLOAD_OBJECT_KEY_PREFIX no longer present. B2 governance handled separately.) --- agent/src/pipeline.py | 5 +++++ cdk/src/constructs/ecs-agent-cluster.ts | 19 +++++++++++-------- cdk/test/constructs/ecs-agent-cluster.test.ts | 17 ++++++++++++----- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/agent/src/pipeline.py b/agent/src/pipeline.py index 5cccea76..ddab154d 100644 --- a/agent/src/pipeline.py +++ b/agent/src/pipeline.py @@ -1303,6 +1303,11 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: _KNOWN_ORCHESTRATOR_KEYS = frozenset( { "build_command", + # ``lint_command``'s sibling: neither is a run_task param today (the build/ + # lint commands are consumed via repo config, not passed through here), but + # listing both makes a future "wired build_command, forgot lint_command" + # contract gap WARN instead of drop silently (N4). + "lint_command", "merge_branches", "base_branch", "github_token_secret_arn", diff --git a/cdk/src/constructs/ecs-agent-cluster.ts b/cdk/src/constructs/ecs-agent-cluster.ts index 67fcfc03..8228c861 100644 --- a/cdk/src/constructs/ecs-agent-cluster.ts +++ b/cdk/src/constructs/ecs-agent-cluster.ts @@ -233,13 +233,16 @@ export class EcsAgentCluster extends Construct { props.payloadBucket.grantRead(taskRole); } - // #299 ECS-parity: an artifact workflow (coding/decompose-v1) WRITES its plan - // to the artifacts bucket via deliver_artifact, so grant read+write (the - // AgentCore runtime's SessionRole/exec-role has the equivalent). Scoped to - // this bucket. Stays on the task role — delivery is a terminal step. - if (props.artifactsBucket) { - props.artifactsBucket.grantReadWrite(taskRole); - } + // #299 ECS-parity: coding/decompose-v1 delivers its plan to the artifacts + // bucket via deliver_artifact — but the write goes through the assumed + // SessionRole (deliverers.py -> tenant_client), scoped to + // artifacts/${task_id}/*, exactly like the AgentCore runtime (whose task + // role likewise has NO direct artifacts grant). So the task role needs only + // the ARTIFACTS_BUCKET_NAME env (set above), not a bucket grant. Granting + // whole-bucket read+write here would over-privilege the untrusted-code role + // and break cross-task isolation (a task could read/clobber other tasks' + // artifacts//, traces/, attachments/ on the same bucket). + // (no props.artifactsBucket grant — intentional; see comment) // F-2 (ABCA-488-class parity): grant the task role read+write on the // AgentCore Memory so the agent's cross-task learning writes @@ -341,7 +344,7 @@ export class EcsAgentCluster extends Construct { NagSuppressions.addResourceSuppressions(this.taskDefinition, [ { id: 'AwsSolutions-IAM5', - reason: 'DynamoDB index/* wildcards from CDK grantReadWriteData (UserConcurrencyTable, and task tables only when no SessionRole is wired); Secrets Manager wildcards from CDK grantRead (GitHub token) and the bgagent-linear-oauth-*/bgagent-jira-oauth-* prefix grant (ABCA-488 — per-workspace channel OAuth tokens are created by the CLI at setup, name unknown at synth, GetSecretValue only); CloudWatch Logs wildcards from CDK grantWrite; S3 object/* wildcard from CDK grantRead on the ECS payload bucket (read-only, scoped to that bucket — #502) and from grantReadWrite on the artifacts bucket (scoped to that bucket — coding/decompose-v1 delivers its plan artifact there, #299). Bedrock InvokeModel is scoped to explicit model/inference-profile ARNs (no wildcard resource). ec2:DescribeAvailabilityZones requires Resource:* (EC2 describe actions have no resource-level scoping) — read-only, no mutation/data access; needed so a CDK target repo\'s `cdk synth` build gate can resolve AZ context on a fresh clone (ECS-parity, no cdk.context.json cache in the container).', + reason: 'DynamoDB index/* wildcards from CDK grantReadWriteData (UserConcurrencyTable, and task tables only when no SessionRole is wired); Secrets Manager wildcards from CDK grantRead (GitHub token) and the bgagent-linear-oauth-*/bgagent-jira-oauth-* prefix grant (ABCA-488 — per-workspace channel OAuth tokens are created by the CLI at setup, name unknown at synth, GetSecretValue only); CloudWatch Logs wildcards from CDK grantWrite; S3 object/* wildcard from CDK grantRead on the ECS payload bucket (read-only, scoped to that bucket — #502). Bedrock InvokeModel is scoped to explicit model/inference-profile ARNs (no wildcard resource). ec2:DescribeAvailabilityZones requires Resource:* (EC2 describe actions have no resource-level scoping) — read-only, no mutation/data access; needed so a CDK target repo\'s `cdk synth` build gate can resolve AZ context on a fresh clone (ECS-parity, no cdk.context.json cache in the container).', }, { id: 'AwsSolutions-ECS2', diff --git a/cdk/test/constructs/ecs-agent-cluster.test.ts b/cdk/test/constructs/ecs-agent-cluster.test.ts index 4ac63b45..929e8c89 100644 --- a/cdk/test/constructs/ecs-agent-cluster.test.ts +++ b/cdk/test/constructs/ecs-agent-cluster.test.ts @@ -557,20 +557,27 @@ describe('EcsAgentCluster artifacts bucket (#299 ECS-parity)', () => { }); }); - test('grants the task role READ + WRITE on the artifacts bucket (it delivers the plan artifact)', () => { + test('does NOT grant the task role write on the artifacts bucket (the scoped SessionRole owns delivery)', () => { + // #596 review B1: coding/decompose-v1 delivers via the assumed SessionRole + // (scoped to artifacts/${task_id}/*), exactly like the AgentCore runtime — + // whose task role likewise has no direct artifacts grant. A whole-bucket + // grantReadWrite here would over-privilege the untrusted-code role and break + // cross-task isolation. The task role gets only the ARTIFACTS_BUCKET_NAME env. const template = createWithArtifactsBucket(); const policies = template.findResources('AWS::IAM::Policy'); - const s3Actions = new Set(); + const s3WriteActions = new Set(); for (const policy of Object.values(policies)) { for (const stmt of policy.Properties.PolicyDocument.Statement) { const actions = Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action]; for (const a of actions) { - if (typeof a === 'string' && a.startsWith('s3:')) s3Actions.add(a); + // Only true S3 mutations — Put*/Delete*. The read-only payload bucket + // (#502) legitimately grants GetObject*/List* on the task role, so those + // are NOT flagged; what must be absent is any write to any S3 bucket. + if (typeof a === 'string' && /^s3:(Put|Delete)/.test(a)) s3WriteActions.add(a); } } } - expect([...s3Actions].some(a => a === 's3:GetObject' || a === 's3:GetObject*')).toBe(true); - expect([...s3Actions].some(a => a === 's3:PutObject' || a === 's3:PutObject*')).toBe(true); + expect([...s3WriteActions]).toEqual([]); }); test('omits ARTIFACTS_BUCKET_NAME when no artifacts bucket is provided', () => {