Skip to content

Commit 209c66a

Browse files
bgagentisadeks
authored andcommitted
fix(ecs): grant ECS task role AgentCore Memory access (F-2, ABCA-488-class)
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 44bb71e)
1 parent 4acccf3 commit 209c66a

2 files changed

Lines changed: 83 additions & 0 deletions

File tree

cdk/src/constructs/ecs-agent-cluster.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import * as s3 from 'aws-cdk-lib/aws-s3';
2828
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
2929
import { NagSuppressions } from 'cdk-nag';
3030
import { Construct } from 'constructs';
31+
import { AgentMemory } from './agent-memory';
3132
import { AgentSessionRole } from './agent-session-role';
3233
import { resolveBedrockModelIds } from './bedrock-models';
3334

@@ -74,6 +75,18 @@ export interface EcsAgentClusterProps {
7475
* retains the legacy direct grants.
7576
*/
7677
readonly agentSessionRole?: AgentSessionRole;
78+
79+
/**
80+
* AgentCore Memory for cross-task learning (F-2 / ABCA-488-class parity). When
81+
* provided, the ECS task role is granted read+write on it so the agent's
82+
* memory writes (write_task_episode / write_repo_learnings →
83+
* ``bedrock-agentcore:CreateEvent``) succeed on the ECS substrate. The
84+
* AgentCore runtime role already gets this via ``agentMemory.grantReadWrite``
85+
* in agent.ts; without the same grant here, memory writes hit AccessDenied and
86+
* silently no-op on ECS (WARN), so learning never persists on an ECS-only
87+
* deployment. Omitted in isolated construct tests / memory-less deployments.
88+
*/
89+
readonly agentMemory?: AgentMemory;
7790
}
7891

7992
/** HTTPS port — the only egress allowed from the agent task ENIs. */
@@ -222,6 +235,17 @@ export class EcsAgentCluster extends Construct {
222235
props.artifactsBucket.grantReadWrite(taskRole);
223236
}
224237

238+
// F-2 (ABCA-488-class parity): grant the task role read+write on the
239+
// AgentCore Memory so the agent's cross-task learning writes
240+
// (write_task_episode / write_repo_learnings → bedrock-agentcore:CreateEvent)
241+
// succeed on ECS. The AgentCore runtime role gets this via
242+
// agentMemory.grantReadWrite(runtime) in agent.ts; without the same grant
243+
// here the writes hit AccessDenied and silently no-op (WARN) on the ECS
244+
// substrate, so learning never persists on an ECS-only deployment.
245+
if (props.agentMemory) {
246+
props.agentMemory.grantReadWrite(taskRole);
247+
}
248+
225249
// ABCA-488: per-workspace Linear/Jira OAuth tokens live in Secrets Manager
226250
// under `bgagent-linear-oauth-*` (written by the CLI at setup). For a
227251
// Linear/Jira-channel task the agent resolves that token at startup

cdk/test/constructs/ecs-agent-cluster.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import * as ecr_assets from 'aws-cdk-lib/aws-ecr-assets';
2626
import * as iam from 'aws-cdk-lib/aws-iam';
2727
import * as s3 from 'aws-cdk-lib/aws-s3';
2828
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
29+
import { AgentMemory } from '../../src/constructs/agent-memory';
2930
import { AgentSessionRole } from '../../src/constructs/agent-session-role';
3031
import { EcsAgentCluster } from '../../src/constructs/ecs-agent-cluster';
3132

@@ -174,6 +175,64 @@ describe('EcsAgentCluster construct', () => {
174175
expect(hasLinearOauthGrant).toBe(true);
175176
});
176177

178+
test('task role gets bedrock-agentcore:CreateEvent on the AgentMemory when wired (F-2 / ABCA-488-class)', () => {
179+
// REGRESSION: the agent's cross-task learning writes (write_task_episode /
180+
// write_repo_learnings) call bedrock-agentcore:CreateEvent on the AgentCore
181+
// Memory. The runtime role gets this via agentMemory.grantReadWrite; the ECS
182+
// task role did NOT, so writes hit AccessDenied and silently no-op'd (WARN)
183+
// on the ECS substrate — learning never persisted on an ECS-only deploy.
184+
// Build a stack WITH an AgentMemory and assert the CreateEvent grant exists,
185+
// scoped to the memory ARN (not a wildcard).
186+
const app = new App();
187+
const stack = new Stack(app, 'EcsMemStack');
188+
const vpc = new ec2.Vpc(stack, 'Vpc', { maxAzs: 2 });
189+
const agentImageAsset = new ecr_assets.DockerImageAsset(stack, 'AgentImage', {
190+
directory: path.join(__dirname, '..', '..', '..', 'agent'),
191+
});
192+
const mk = (id: string) =>
193+
new dynamodb.Table(stack, id, { partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING } });
194+
const userConcurrencyTable = new dynamodb.Table(stack, 'UserConcurrencyTable', {
195+
partitionKey: { name: 'user_id', type: dynamodb.AttributeType.STRING },
196+
});
197+
const agentMemory = new AgentMemory(stack, 'AgentMemory');
198+
new EcsAgentCluster(stack, 'EcsAgentCluster', {
199+
vpc,
200+
agentImageAsset,
201+
taskTable: mk('TaskTable'),
202+
taskEventsTable: mk('TaskEventsTable'),
203+
userConcurrencyTable,
204+
githubTokenSecret: new secretsmanager.Secret(stack, 'GitHubTokenSecret'),
205+
agentMemory,
206+
});
207+
const template = Template.fromStack(stack);
208+
const policies = template.findResources('AWS::IAM::Policy');
209+
let hasCreateEvent = false;
210+
for (const [id, p] of Object.entries(policies)) {
211+
if (!id.includes('TaskDefTaskRole')) continue;
212+
for (const s of p.Properties.PolicyDocument.Statement) {
213+
const actions = Array.isArray(s.Action) ? s.Action : [s.Action];
214+
if (actions.includes('bedrock-agentcore:CreateEvent')) {
215+
hasCreateEvent = true;
216+
// resource must reference the memory ARN, not a bare wildcard
217+
expect(JSON.stringify(s.Resource)).toContain('MemoryArn');
218+
expect(s.Resource).not.toEqual('*');
219+
}
220+
}
221+
}
222+
expect(hasCreateEvent).toBe(true);
223+
});
224+
225+
test('task role has NO bedrock-agentcore grant when no AgentMemory is wired (isolated default)', () => {
226+
const policies = baseTemplate.findResources('AWS::IAM::Policy');
227+
for (const [id, p] of Object.entries(policies)) {
228+
if (!id.includes('TaskDefTaskRole')) continue;
229+
for (const s of p.Properties.PolicyDocument.Statement) {
230+
const actions = Array.isArray(s.Action) ? s.Action : [s.Action];
231+
expect(actions.some((a: string) => a.startsWith('bedrock-agentcore:'))).toBe(false);
232+
}
233+
}
234+
});
235+
177236
test('task role Bedrock InvokeModel is scoped to explicit model/inference-profile ARNs (no wildcard)', () => {
178237
const policies = baseTemplate.findResources('AWS::IAM::Policy');
179238
let bedrockStatement: { Resource: unknown } | undefined;

0 commit comments

Comments
 (0)