multi-tenant Phase 2: per-user Google OAuth

- workspace-mcp: add proxy.py (port 8080) that reads X-Actor-Id header,
  fetches per-user Google credentials from Secrets Manager, writes creds
  file, sets USER_GOOGLE_EMAIL, proxies to workspace-mcp on port 8081
- workspace-mcp: update bootstrap to start workspace-mcp on 8081 + proxy on 8080
- workspace-mcp: update Dockerfile to include proxy.py
- oauth-handler Lambda: new Lambda with /oauth/start + /oauth/callback
  routes; exchanges Google auth code, stores tokens in Secrets Manager
  at agent-claw/google-credentials/{actor_id_safe}, updates DynamoDB
- CDK: add OAuthHandler Lambda + GET /oauth/start + /oauth/callback routes
- CDK: remove shared google-workspace-credentials secret; add per-user
  secret IAM grants (agent-claw/google-credentials/*) for workspace-mcp
  role, runtime1 role, and oauth-handler role
- CDK: output OAuthStartUrl + OAuthRedirectUri
- agent-runner: pass google_email in user_profile payload
- main.py: pass actor_id as X-Actor-Id header in workspace-mcp MCP calls;
  skip workspace-mcp if user has no google_email; add connect_google_account
  tool that generates OAuth URL for the current user
- main.py: include google_email in user_context for system prompt
- agentcore.json: add OAUTH_START_URL env var for agent runtime
This commit is contained in:
daniel
2026-05-06 21:42:33 -05:00
parent 841e729b18
commit ac5bd78d5a
24 changed files with 1736 additions and 95 deletions

View File

@@ -16,31 +16,46 @@
}
}
},
"7053cd1618f5f520a7aac409588128f920d8fe76791c1dbcc65610454d1a5387": {
"6f6fdf79f33a947f3e50ffd783a72d04ab5f29ba299a5d51b3ecd2c2eb311370": {
"displayName": "AgentRunner/Code",
"source": {
"path": "asset.7053cd1618f5f520a7aac409588128f920d8fe76791c1dbcc65610454d1a5387",
"path": "asset.6f6fdf79f33a947f3e50ffd783a72d04ab5f29ba299a5d51b3ecd2c2eb311370",
"packaging": "zip"
},
"destinations": {
"495395224548-us-east-1-63ace858": {
"495395224548-us-east-1-ab491e35": {
"bucketName": "cdk-hnb659fds-assets-495395224548-us-east-1",
"objectKey": "7053cd1618f5f520a7aac409588128f920d8fe76791c1dbcc65610454d1a5387.zip",
"objectKey": "6f6fdf79f33a947f3e50ffd783a72d04ab5f29ba299a5d51b3ecd2c2eb311370.zip",
"region": "us-east-1",
"assumeRoleArn": "arn:${AWS::Partition}:iam::495395224548:role/cdk-hnb659fds-file-publishing-role-495395224548-us-east-1"
}
}
},
"2765094d543818b111d837ea62bad41260a47615c5b99bc608a58e99f24d5b85": {
"5be87975e51a6859dfad098b3d998a0bcd09a4f9a437bbf38923338fb559eb9e": {
"displayName": "OAuthHandler/Code",
"source": {
"path": "asset.5be87975e51a6859dfad098b3d998a0bcd09a4f9a437bbf38923338fb559eb9e",
"packaging": "zip"
},
"destinations": {
"495395224548-us-east-1-23c3d77a": {
"bucketName": "cdk-hnb659fds-assets-495395224548-us-east-1",
"objectKey": "5be87975e51a6859dfad098b3d998a0bcd09a4f9a437bbf38923338fb559eb9e.zip",
"region": "us-east-1",
"assumeRoleArn": "arn:${AWS::Partition}:iam::495395224548:role/cdk-hnb659fds-file-publishing-role-495395224548-us-east-1"
}
}
},
"fdf1ff81e9e0ded898f1c1d03a2bb8bbe0bbf63689426c24072f179b49b527c6": {
"displayName": "AgentClawStack Template",
"source": {
"path": "AgentClawStack.template.json",
"packaging": "file"
},
"destinations": {
"495395224548-us-east-1-b10aaf8d": {
"495395224548-us-east-1-9bba4277": {
"bucketName": "cdk-hnb659fds-assets-495395224548-us-east-1",
"objectKey": "2765094d543818b111d837ea62bad41260a47615c5b99bc608a58e99f24d5b85.json",
"objectKey": "fdf1ff81e9e0ded898f1c1d03a2bb8bbe0bbf63689426c24072f179b49b527c6.json",
"region": "us-east-1",
"assumeRoleArn": "arn:${AWS::Partition}:iam::495395224548:role/cdk-hnb659fds-file-publishing-role-495395224548-us-east-1"
}

View File

@@ -32,21 +32,35 @@
{
"type": "aws:cdk:creationStack",
"data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:234:5)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:289:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
}
],
"/AgentClawStack/GoogleCredentialsSecretArn": [
"/AgentClawStack/OAuthStartUrl": [
{
"type": "aws:cdk:logicalId",
"data": "GoogleCredentialsSecretArn"
"data": "OAuthStartUrl"
},
{
"type": "aws:cdk:creationStack",
"data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:238:5)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:293:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
}
],
"/AgentClawStack/OAuthRedirectUri": [
{
"type": "aws:cdk:logicalId",
"data": "OAuthRedirectUri"
},
{
"type": "aws:cdk:creationStack",
"data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:297:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -60,7 +74,7 @@
{
"type": "aws:cdk:creationStack",
"data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:243:5)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:302:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -74,7 +88,7 @@
{
"type": "aws:cdk:creationStack",
"data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:248:5)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:307:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -88,7 +102,7 @@
{
"type": "aws:cdk:creationStack",
"data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:253:5)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:312:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -102,7 +116,7 @@
{
"type": "aws:cdk:creationStack",
"data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:258:5)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:317:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -116,7 +130,7 @@
{
"type": "aws:cdk:creationStack",
"data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:263:5)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:322:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -130,7 +144,7 @@
{
"type": "aws:cdk:creationStack",
"data": [
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:268:5)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:327:5)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -267,6 +281,21 @@
]
}
],
"/AgentClawStack/OAuthHandler/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "OAuthHandlerC97C2476"
},
{
"type": "aws:cdk:creationStack",
"data": [
"...new Function2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:235:28)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
}
],
"/AgentClawStack/CDKMetadata/Default": [
{
"type": "aws:cdk:logicalId",
@@ -374,6 +403,74 @@
]
}
],
"/AgentClawStack/WebhookApi/GET--oauth--start/OAuthStartIntegration-Permission": [
{
"type": "aws:cdk:logicalId",
"data": "WebhookApiGEToauthstartOAuthStartIntegrationPermission38BAEF6D"
},
{
"type": "aws:cdk:creationStack",
"data": [
".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...",
"Array.map (:)",
"...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:263:13)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
}
],
"/AgentClawStack/WebhookApi/GET--oauth--start/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "WebhookApiGEToauthstart6DCA713A"
},
{
"type": "aws:cdk:creationStack",
"data": [
".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...",
"Array.map (:)",
"...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:263:13)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
}
],
"/AgentClawStack/WebhookApi/GET--oauth--callback/OAuthCallbackIntegration-Permission": [
{
"type": "aws:cdk:logicalId",
"data": "WebhookApiGEToauthcallbackOAuthCallbackIntegrationPermission6BA3A5AD"
},
{
"type": "aws:cdk:creationStack",
"data": [
".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...",
"Array.map (:)",
"...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:270:13)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
}
],
"/AgentClawStack/WebhookApi/GET--oauth--callback/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "WebhookApiGEToauthcallbackFC1F6BCD"
},
{
"type": "aws:cdk:creationStack",
"data": [
".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...",
"Array.map (:)",
"...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:270:13)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
}
],
"/AgentClawStack/Runtime1Role/DefaultPolicy/Resource": [
{
"type": "aws:cdk:logicalId",
@@ -398,7 +495,22 @@
"type": "aws:cdk:creationStack",
"data": [
"...SecretBase.grantRead in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:207:29)",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:198:29)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
}
],
"/AgentClawStack/OAuthHandler/ServiceRole/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "OAuthHandlerServiceRole9CDCCF9E"
},
{
"type": "aws:cdk:creationStack",
"data": [
"...new Function2 in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:235:28)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
@@ -450,5 +562,54 @@
"...node internals, ts-node, ts-node, ts-node..."
]
}
],
"/AgentClawStack/WebhookApi/GET--oauth--start/OAuthStartIntegration/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "WebhookApiGEToauthstartOAuthStartIntegrationA546443F"
},
{
"type": "aws:cdk:creationStack",
"data": [
".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...",
"Array.map (:)",
"...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:263:13)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
}
],
"/AgentClawStack/WebhookApi/GET--oauth--callback/OAuthCallbackIntegration/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "WebhookApiGEToauthcallbackOAuthCallbackIntegrationCFBBEB09"
},
{
"type": "aws:cdk:creationStack",
"data": [
".../Users/daniel/agent-claw/cdk/node_modules/aws-cdk-lib/aws-apigatewayv2/lib/http/api.js:1:96 in aws-cdk-lib...",
"Array.map (:)",
"...WrappedClass.<anonymous> in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:270:13)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
}
],
"/AgentClawStack/OAuthHandler/ServiceRole/DefaultPolicy/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "OAuthHandlerServiceRoleDefaultPolicy69D90416"
},
{
"type": "aws:cdk:creationStack",
"data": [
"...SecretBase.grantRead in aws-cdk-lib...",
"new AgentClawStack (/Users/daniel/agent-claw/cdk/lib/agent-claw-stack.ts:249:29)",
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
"...node internals, ts-node, ts-node, ts-node..."
]
}
]
}

View File

@@ -387,7 +387,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-495395224548-us-east-1",
"S3Key": "7053cd1618f5f520a7aac409588128f920d8fe76791c1dbcc65610454d1a5387.zip"
"S3Key": "6f6fdf79f33a947f3e50ffd783a72d04ab5f29ba299a5d51b3ecd2c2eb311370.zip"
},
"Environment": {
"Variables": {
@@ -423,7 +423,7 @@
],
"Metadata": {
"aws:cdk:path": "AgentClawStack/AgentRunner/Resource",
"aws:asset:path": "asset.7053cd1618f5f520a7aac409588128f920d8fe76791c1dbcc65610454d1a5387",
"aws:asset:path": "asset.6f6fdf79f33a947f3e50ffd783a72d04ab5f29ba299a5d51b3ecd2c2eb311370",
"aws:asset:is-bundled": false,
"aws:asset:property": "Code"
}
@@ -545,6 +545,156 @@
"aws:cdk:path": "AgentClawStack/WebhookApi/POST--telegram/Resource"
}
},
"WebhookApiGEToauthstartOAuthStartIntegrationA546443F": {
"Type": "AWS::ApiGatewayV2::Integration",
"Properties": {
"ApiId": {
"Ref": "WebhookApi28122C53"
},
"IntegrationType": "AWS_PROXY",
"IntegrationUri": {
"Fn::GetAtt": [
"OAuthHandlerC97C2476",
"Arn"
]
},
"PayloadFormatVersion": "2.0"
},
"Metadata": {
"aws:cdk:path": "AgentClawStack/WebhookApi/GET--oauth--start/OAuthStartIntegration/Resource"
}
},
"WebhookApiGEToauthstartOAuthStartIntegrationPermission38BAEF6D": {
"Type": "AWS::Lambda::Permission",
"Properties": {
"Action": "lambda:InvokeFunction",
"FunctionName": {
"Fn::GetAtt": [
"OAuthHandlerC97C2476",
"Arn"
]
},
"Principal": "apigateway.amazonaws.com",
"SourceArn": {
"Fn::Join": [
"",
[
"arn:",
{
"Ref": "AWS::Partition"
},
":execute-api:us-east-1:495395224548:",
{
"Ref": "WebhookApi28122C53"
},
"/*/*/oauth/start"
]
]
}
},
"Metadata": {
"aws:cdk:path": "AgentClawStack/WebhookApi/GET--oauth--start/OAuthStartIntegration-Permission"
}
},
"WebhookApiGEToauthstart6DCA713A": {
"Type": "AWS::ApiGatewayV2::Route",
"Properties": {
"ApiId": {
"Ref": "WebhookApi28122C53"
},
"AuthorizationType": "NONE",
"RouteKey": "GET /oauth/start",
"Target": {
"Fn::Join": [
"",
[
"integrations/",
{
"Ref": "WebhookApiGEToauthstartOAuthStartIntegrationA546443F"
}
]
]
}
},
"Metadata": {
"aws:cdk:path": "AgentClawStack/WebhookApi/GET--oauth--start/Resource"
}
},
"WebhookApiGEToauthcallbackOAuthCallbackIntegrationCFBBEB09": {
"Type": "AWS::ApiGatewayV2::Integration",
"Properties": {
"ApiId": {
"Ref": "WebhookApi28122C53"
},
"IntegrationType": "AWS_PROXY",
"IntegrationUri": {
"Fn::GetAtt": [
"OAuthHandlerC97C2476",
"Arn"
]
},
"PayloadFormatVersion": "2.0"
},
"Metadata": {
"aws:cdk:path": "AgentClawStack/WebhookApi/GET--oauth--callback/OAuthCallbackIntegration/Resource"
}
},
"WebhookApiGEToauthcallbackOAuthCallbackIntegrationPermission6BA3A5AD": {
"Type": "AWS::Lambda::Permission",
"Properties": {
"Action": "lambda:InvokeFunction",
"FunctionName": {
"Fn::GetAtt": [
"OAuthHandlerC97C2476",
"Arn"
]
},
"Principal": "apigateway.amazonaws.com",
"SourceArn": {
"Fn::Join": [
"",
[
"arn:",
{
"Ref": "AWS::Partition"
},
":execute-api:us-east-1:495395224548:",
{
"Ref": "WebhookApi28122C53"
},
"/*/*/oauth/callback"
]
]
}
},
"Metadata": {
"aws:cdk:path": "AgentClawStack/WebhookApi/GET--oauth--callback/OAuthCallbackIntegration-Permission"
}
},
"WebhookApiGEToauthcallbackFC1F6BCD": {
"Type": "AWS::ApiGatewayV2::Route",
"Properties": {
"ApiId": {
"Ref": "WebhookApi28122C53"
},
"AuthorizationType": "NONE",
"RouteKey": "GET /oauth/callback",
"Target": {
"Fn::Join": [
"",
[
"integrations/",
{
"Ref": "WebhookApiGEToauthcallbackOAuthCallbackIntegrationCFBBEB09"
}
]
]
}
},
"Metadata": {
"aws:cdk:path": "AgentClawStack/WebhookApi/GET--oauth--callback/Resource"
}
},
"Runtime1RoleA7A82078": {
"Type": "AWS::IAM::Role",
"Properties": {
@@ -674,29 +824,16 @@
{
"Ref": "AWS::Partition"
},
":secretsmanager:us-east-1:495395224548:secret:agent-claw/google-workspace-credentials-??????"
":secretsmanager:us-east-1:495395224548:secret:agent-claw/google-oauth-client-??????"
]
]
}
},
{
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
],
"Action": "secretsmanager:GetSecretValue",
"Effect": "Allow",
"Resource": {
"Fn::Join": [
"",
[
"arn:",
{
"Ref": "AWS::Partition"
},
":secretsmanager:us-east-1:495395224548:secret:agent-claw/google-oauth-client-??????"
]
]
}
"Resource": "arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/google-credentials/*",
"Sid": "PerUserGoogleCredentialsReadRuntime"
}
],
"Version": "2012-10-17"
@@ -731,11 +868,68 @@
{
"Ref": "AWS::Partition"
},
":secretsmanager:us-east-1:495395224548:secret:agent-claw/google-workspace-credentials-??????"
":secretsmanager:us-east-1:495395224548:secret:agent-claw/google-oauth-client-??????"
]
]
}
},
{
"Action": "secretsmanager:GetSecretValue",
"Effect": "Allow",
"Resource": "arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/google-credentials/*",
"Sid": "PerUserGoogleCredentialsRead"
}
],
"Version": "2012-10-17"
},
"PolicyName": "WorkspaceMcpRolePolicy5B8B0072",
"Roles": [
"agent-claw-workspace-mcp-role"
]
},
"Metadata": {
"aws:cdk:path": "AgentClawStack/WorkspaceMcpRole/Policy/Resource"
}
},
"OAuthHandlerServiceRole9CDCCF9E": {
"Type": "AWS::IAM::Role",
"Properties": {
"AssumeRolePolicyDocument": {
"Statement": [
{
"Action": "sts:AssumeRole",
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
}
}
],
"Version": "2012-10-17"
},
"ManagedPolicyArns": [
{
"Fn::Join": [
"",
[
"arn:",
{
"Ref": "AWS::Partition"
},
":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
]
]
}
]
},
"Metadata": {
"aws:cdk:path": "AgentClawStack/OAuthHandler/ServiceRole/Resource"
}
},
"OAuthHandlerServiceRoleDefaultPolicy69D90416": {
"Type": "AWS::IAM::Policy",
"Properties": {
"PolicyDocument": {
"Statement": [
{
"Action": [
"secretsmanager:GetSecretValue",
@@ -754,17 +948,132 @@
]
]
}
},
{
"Action": [
"dynamodb:BatchGetItem",
"dynamodb:Query",
"dynamodb:GetItem",
"dynamodb:Scan",
"dynamodb:ConditionCheckItem",
"dynamodb:BatchWriteItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:DescribeTable"
],
"Effect": "Allow",
"Resource": [
{
"Fn::GetAtt": [
"UsersTable9725E9C8",
"Arn"
]
}
]
},
{
"Action": [
"dynamodb:GetRecords",
"dynamodb:GetShardIterator"
],
"Effect": "Allow",
"Resource": [
{
"Fn::GetAtt": [
"UsersTable9725E9C8",
"Arn"
]
}
]
},
{
"Action": [
"secretsmanager:CreateSecret",
"secretsmanager:PutSecretValue",
"secretsmanager:GetSecretValue"
],
"Effect": "Allow",
"Resource": "arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/google-credentials/*",
"Sid": "PerUserGoogleCredentialsWrite"
}
],
"Version": "2012-10-17"
},
"PolicyName": "WorkspaceMcpRolePolicy5B8B0072",
"PolicyName": "OAuthHandlerServiceRoleDefaultPolicy69D90416",
"Roles": [
"agent-claw-workspace-mcp-role"
{
"Ref": "OAuthHandlerServiceRole9CDCCF9E"
}
]
},
"Metadata": {
"aws:cdk:path": "AgentClawStack/WorkspaceMcpRole/Policy/Resource"
"aws:cdk:path": "AgentClawStack/OAuthHandler/ServiceRole/DefaultPolicy/Resource"
}
},
"OAuthHandlerC97C2476": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-495395224548-us-east-1",
"S3Key": "5be87975e51a6859dfad098b3d998a0bcd09a4f9a437bbf38923338fb559eb9e.zip"
},
"Environment": {
"Variables": {
"GOOGLE_OAUTH_CLIENT_SECRET_ARN": {
"Fn::Join": [
"",
[
"arn:",
{
"Ref": "AWS::Partition"
},
":secretsmanager:us-east-1:495395224548:secret:agent-claw/google-oauth-client"
]
]
},
"USERS_TABLE_NAME": {
"Ref": "UsersTable9725E9C8"
},
"OAUTH_REDIRECT_URI": {
"Fn::Join": [
"",
[
"https://",
{
"Ref": "WebhookApi28122C53"
},
".execute-api.us-east-1.",
{
"Ref": "AWS::URLSuffix"
},
"/oauth/callback"
]
]
}
}
},
"FunctionName": "agent-claw-oauth-handler",
"Handler": "handler.handler",
"MemorySize": 128,
"Role": {
"Fn::GetAtt": [
"OAuthHandlerServiceRole9CDCCF9E",
"Arn"
]
},
"Runtime": "python3.12",
"Timeout": 30
},
"DependsOn": [
"OAuthHandlerServiceRoleDefaultPolicy69D90416",
"OAuthHandlerServiceRole9CDCCF9E"
],
"Metadata": {
"aws:cdk:path": "AgentClawStack/OAuthHandler/Resource",
"aws:asset:path": "asset.5be87975e51a6859dfad098b3d998a0bcd09a4f9a437bbf38923338fb559eb9e",
"aws:asset:is-bundled": false,
"aws:asset:property": "Code"
}
},
"CDKMetadata": {
@@ -782,17 +1091,40 @@
"Description": "workspace-mcp Lambda Function URL (MCP endpoint for Gmail/Calendar)",
"Value": "https://25hugrzw4uwtueeg77jsmft6lq0wunmd.lambda-url.us-east-1.on.aws"
},
"GoogleCredentialsSecretArn": {
"Description": "Google OAuth user credentials secret ARN",
"OAuthStartUrl": {
"Description": "Google OAuth start URL — set as OAUTH_START_URL in agentcore.json",
"Value": {
"Fn::Join": [
"",
[
"arn:",
"https://",
{
"Ref": "AWS::Partition"
"Ref": "WebhookApi28122C53"
},
":secretsmanager:us-east-1:495395224548:secret:agent-claw/google-workspace-credentials"
".execute-api.us-east-1.",
{
"Ref": "AWS::URLSuffix"
},
"/oauth/start"
]
]
}
},
"OAuthRedirectUri": {
"Description": "Google OAuth redirect URI — register in Google Cloud Console",
"Value": {
"Fn::Join": [
"",
[
"https://",
{
"Ref": "WebhookApi28122C53"
},
".execute-api.us-east-1.",
{
"Ref": "AWS::URLSuffix"
},
"/oauth/callback"
]
]
}

View File

@@ -0,0 +1,191 @@
import json
import os
import time
import uuid
import boto3
import urllib.request
from typing import Any
# AWS clients
_ddb = None
_agentcore = None
def get_ddb():
global _ddb
if _ddb is None:
_ddb = boto3.resource('dynamodb')
return _ddb
def get_agentcore():
global _agentcore
if _agentcore is None:
_agentcore = boto3.client('bedrock-agentcore', region_name='us-east-1')
return _agentcore
def get_or_create_user(actor_id: str, from_info: dict) -> dict:
"""Look up user in registry, auto-registering on first contact."""
table_name = os.environ.get('USERS_TABLE_NAME', '')
if not table_name:
return {'actor_id': actor_id, 'display_name': from_info.get('from_name', actor_id)}
table = get_ddb().Table(table_name)
response = table.get_item(Key={'actor_id': actor_id})
item = response.get('Item')
if item:
return item
now = int(time.time())
item = {
'actor_id': actor_id,
'display_name': from_info.get('from_name') or actor_id,
'telegram_username': from_info.get('from_username', ''),
'created_at': str(now),
'status': 'pending',
}
table.put_item(Item=item)
print(f'[agent-runner] Registered new user (pending): {actor_id}')
return item
def update_user_status(actor_id: str, name: str, status: str) -> None:
table_name = os.environ.get('USERS_TABLE_NAME', '')
if not table_name:
return
table = get_ddb().Table(table_name)
table.update_item(
Key={'actor_id': actor_id},
UpdateExpression='SET display_name = :n, #s = :s',
ExpressionAttributeNames={'#s': 'status'},
ExpressionAttributeValues={':n': name, ':s': status},
)
def send_telegram_direct(chat_id: str, token: str, text: str) -> None:
url = f'https://api.telegram.org/bot{token}/sendMessage'
data = json.dumps({'chat_id': chat_id, 'text': text}).encode()
req = urllib.request.Request(url, data=data, headers={'Content-Type': 'application/json'})
urllib.request.urlopen(req, timeout=10)
def get_or_create_session(actor_id: str) -> str:
"""Look up active session for actor, or create a new one."""
table = get_ddb().Table(os.environ['SESSION_TABLE_NAME'])
response = table.get_item(Key={'actor_id': actor_id})
item = response.get('Item')
now = int(time.time())
ttl_8hr = now + (8 * 3600)
if item and item.get('ttl', 0) > now:
# Active session exists — extend TTL
table.update_item(
Key={'actor_id': actor_id},
UpdateExpression='SET #ttl = :ttl',
ExpressionAttributeNames={'#ttl': 'ttl'},
ExpressionAttributeValues={':ttl': ttl_8hr},
)
return item['session_id']
# Create new session
session_id = str(uuid.uuid4())
table.put_item(Item={
'actor_id': actor_id,
'session_id': session_id,
'created_at': str(now),
'ttl': ttl_8hr,
})
return session_id
def handler(event, context):
# ── Parse SQS records (FIFO — all from same actor) ───────────────────
records = []
for record in event.get('Records', []):
try:
records.append(json.loads(record['body']))
except (json.JSONDecodeError, KeyError):
continue
if not records:
return
first = records[0]
channel = first.get('channel', 'telegram')
chat_id = first.get('chat_id', '')
actor_id = f"{channel}:{chat_id}"
# ── User registry ─────────────────────────────────────────────────────
from_info = first.get('messages', [{}])[0]
user_profile = get_or_create_user(actor_id, from_info)
# ── Onboarding gate ─────────────────────────────────────────────────────
table_name = os.environ.get('USERS_TABLE_NAME', '')
if table_name and user_profile.get('status', 'active') == 'pending':
raw_prompt = records[0]['messages'][0]['text'] if records else ''
is_name_msg = bool(raw_prompt and len(raw_prompt.strip()) < 50 and '?' not in raw_prompt)
if is_name_msg:
name = raw_prompt.strip()
update_user_status(actor_id, name=name, status='active')
user_profile['display_name'] = name
user_profile['status'] = 'active'
prompt = f"[System: User just registered with name '{name}'. Welcome them warmly and ask how you can help.]"
else:
bot_token_secret_arn = os.environ.get('TELEGRAM_BOT_TOKEN_SECRET_ARN', '')
bot_token = ''
if bot_token_secret_arn:
sm = boto3.client('secretsmanager', region_name='us-east-1')
bot_token = sm.get_secret_value(SecretId=bot_token_secret_arn)['SecretString']
send_telegram_direct(chat_id, bot_token, "Hi! I don't recognize you yet. What's your name?")
return
# ── Get or create AgentCore session ──────────────────────────────────
session_id = get_or_create_session(actor_id)
print(f"[agent-runner] actor={actor_id} session={session_id} user={user_profile.get('display_name', '')}")
# ── Bundle messages ───────────────────────────────────────────────────
if len(records) == 1:
prompt = records[0]['messages'][0]['text']
else:
lines = [
f"[{i+1}] {r['messages'][0]['text']}"
for i, r in enumerate(records)
]
prompt = f"You have {len(records)} queued messages:\n" + "\n".join(lines)
# ── Build payload for AgentCore Runtime 1 ────────────────────────────
payload: dict[str, Any] = {
'prompt': prompt,
'actor_id': actor_id,
'session_id': session_id,
'user_profile': {
'display_name': user_profile.get('display_name', actor_id),
'telegram_username': user_profile.get('telegram_username', ''),
'allowed': user_profile.get('allowed', True),
},
'channel_adapter': {
'type': channel,
'target_id': str(chat_id),
'bot_token_secret_arn': os.environ.get('TELEGRAM_BOT_TOKEN_SECRET_ARN', ''),
},
}
# ── Invoke AgentCore Runtime 1 ────────────────────────────────────────
runtime_arn = os.environ.get('RUNTIME_1_ARN', '')
if not runtime_arn or runtime_arn == 'PLACEHOLDER_SET_AFTER_RUNTIME_DEPLOY':
print(f"[agent-runner] RUNTIME_1_ARN not set — skipping AgentCore invoke")
print(f"[agent-runner] Would have sent: {json.dumps(payload)[:200]}")
return
client = get_agentcore()
response = client.invoke_agent_runtime(
agentRuntimeArn=runtime_arn,
runtimeSessionId=session_id,
payload=json.dumps(payload).encode(),
)
# Consume streaming response (agent delivers to Telegram via send_message tool)
for chunk in response.get('response', []):
pass # intentional no-op — agent handles delivery internally
print(f"[agent-runner] Completed session={session_id} actor={actor_id}")

View File

@@ -0,0 +1,212 @@
"""
Google OAuth handler Lambda.
Routes:
GET /oauth/start?actor_id=telegram:123 → redirect to Google OAuth consent
GET /oauth/callback?code=...&state=... → exchange code, store tokens, update DynamoDB
"""
import base64
import hashlib
import hmac
import json
import os
import time
import urllib.parse
import urllib.request
import boto3
_sm = None
_ddb = None
SCOPES = ' '.join([
'https://www.googleapis.com/auth/gmail.modify',
'https://www.googleapis.com/auth/calendar',
'https://www.googleapis.com/auth/drive',
'https://www.googleapis.com/auth/spreadsheets',
'https://www.googleapis.com/auth/documents',
'openid',
'email',
'profile',
])
def get_sm():
global _sm
if _sm is None:
_sm = boto3.client('secretsmanager', region_name=os.environ.get('AWS_REGION', 'us-east-1'))
return _sm
def get_ddb():
global _ddb
if _ddb is None:
_ddb = boto3.resource('dynamodb')
return _ddb
def get_oauth_client() -> tuple[str, str]:
"""Return (client_id, client_secret) from Secrets Manager."""
arn = os.environ['GOOGLE_OAUTH_CLIENT_SECRET_ARN']
secret = json.loads(get_sm().get_secret_value(SecretId=arn)['SecretString'])
return secret['client_id'], secret['client_secret']
def actor_id_to_secret_name(actor_id: str) -> str:
safe = actor_id.replace(':', '-').replace('/', '-')
return f'agent-claw/google-credentials/{safe}'
def _redirect(url: str) -> dict:
return {'statusCode': 302, 'headers': {'Location': url}, 'body': ''}
def _html(body: str, status: int = 200) -> dict:
return {'statusCode': status, 'headers': {'Content-Type': 'text/html'}, 'body': body}
def handler(event, context):
path = event.get('rawPath') or event.get('path', '')
params = event.get('queryStringParameters') or {}
if path.endswith('/oauth/start'):
return handle_start(params)
elif path.endswith('/oauth/callback'):
return handle_callback(params)
else:
return {'statusCode': 404, 'body': 'Not found'}
def handle_start(params: dict) -> dict:
actor_id = params.get('actor_id', '')
if not actor_id:
return _html('<h1>Missing actor_id</h1>', 400)
client_id, _ = get_oauth_client()
redirect_uri = os.environ['OAUTH_REDIRECT_URI']
# Encode actor_id in state (base64 to keep URL-safe)
state = base64.urlsafe_b64encode(actor_id.encode()).decode().rstrip('=')
auth_url = (
'https://accounts.google.com/o/oauth2/v2/auth?'
+ urllib.parse.urlencode({
'client_id': client_id,
'redirect_uri': redirect_uri,
'response_type': 'code',
'scope': SCOPES,
'access_type': 'offline',
'prompt': 'consent',
'state': state,
})
)
return _redirect(auth_url)
def handle_callback(params: dict) -> dict:
code = params.get('code', '')
state = params.get('state', '')
error = params.get('error', '')
if error:
return _html(f'<h1>OAuth error: {error}</h1>', 400)
if not code or not state:
return _html('<h1>Missing code or state</h1>', 400)
# Decode actor_id from state
try:
padding = 4 - len(state) % 4
actor_id = base64.urlsafe_b64decode(state + '=' * padding).decode()
except Exception:
return _html('<h1>Invalid state</h1>', 400)
client_id, client_secret = get_oauth_client()
redirect_uri = os.environ['OAUTH_REDIRECT_URI']
# Exchange code for tokens
token_data = urllib.parse.urlencode({
'code': code,
'client_id': client_id,
'client_secret': client_secret,
'redirect_uri': redirect_uri,
'grant_type': 'authorization_code',
}).encode()
req = urllib.request.Request(
'https://oauth2.googleapis.com/token',
data=token_data,
headers={'Content-Type': 'application/x-www-form-urlencoded'},
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
tokens = json.loads(resp.read())
except Exception as e:
print(f'[oauth] Token exchange failed: {e}')
return _html(f'<h1>Token exchange failed: {e}</h1>', 500)
# Fetch user email from Google
user_email = ''
try:
id_token_payload = tokens.get('id_token', '').split('.')[1]
padding = 4 - len(id_token_payload) % 4
claims = json.loads(base64.urlsafe_b64decode(id_token_payload + '=' * padding))
user_email = claims.get('email', '')
except Exception:
pass
if not user_email:
# Fallback: call userinfo endpoint
try:
access_token = tokens.get('access_token', '')
req2 = urllib.request.Request(
'https://www.googleapis.com/oauth2/v3/userinfo',
headers={'Authorization': f'Bearer {access_token}'},
)
with urllib.request.urlopen(req2, timeout=10) as resp2:
user_email = json.loads(resp2.read()).get('email', '')
except Exception as e:
print(f'[oauth] userinfo fetch failed: {e}')
# Build credentials dict (google-auth format)
creds = {
'token': tokens.get('access_token'),
'refresh_token': tokens.get('refresh_token'),
'token_uri': 'https://oauth2.googleapis.com/token',
'client_id': client_id,
'client_secret': client_secret,
'scopes': SCOPES.split(),
'email': user_email,
'user_email': user_email,
}
if tokens.get('expires_in'):
creds['expiry'] = time.strftime(
'%Y-%m-%dT%H:%M:%SZ',
time.gmtime(time.time() + int(tokens['expires_in']))
)
# Store in Secrets Manager
secret_name = actor_id_to_secret_name(actor_id)
sm = get_sm()
try:
sm.create_secret(Name=secret_name, SecretString=json.dumps(creds))
except sm.exceptions.ResourceExistsException:
sm.put_secret_value(SecretId=secret_name, SecretString=json.dumps(creds))
print(f'[oauth] Stored credentials for actor={actor_id} email={user_email}')
# Update DynamoDB users table with google_email
table_name = os.environ.get('USERS_TABLE_NAME', '')
if table_name and actor_id:
try:
get_ddb().Table(table_name).update_item(
Key={'actor_id': actor_id},
UpdateExpression='SET google_email = :e',
ExpressionAttributeValues={':e': user_email},
)
except Exception as e:
print(f'[oauth] DynamoDB update failed: {e}')
return _html(
f'<h1>✅ Google account connected!</h1>'
f'<p>Connected <b>{user_email}</b> to your agent account.</p>'
f'<p>You can close this window and return to Telegram.</p>'
)

View File

@@ -0,0 +1,192 @@
import json
import os
import time
import uuid
import boto3
import urllib.request
from typing import Any
# AWS clients
_ddb = None
_agentcore = None
def get_ddb():
global _ddb
if _ddb is None:
_ddb = boto3.resource('dynamodb')
return _ddb
def get_agentcore():
global _agentcore
if _agentcore is None:
_agentcore = boto3.client('bedrock-agentcore', region_name='us-east-1')
return _agentcore
def get_or_create_user(actor_id: str, from_info: dict) -> dict:
"""Look up user in registry, auto-registering on first contact."""
table_name = os.environ.get('USERS_TABLE_NAME', '')
if not table_name:
return {'actor_id': actor_id, 'display_name': from_info.get('from_name', actor_id)}
table = get_ddb().Table(table_name)
response = table.get_item(Key={'actor_id': actor_id})
item = response.get('Item')
if item:
return item
now = int(time.time())
item = {
'actor_id': actor_id,
'display_name': from_info.get('from_name') or actor_id,
'telegram_username': from_info.get('from_username', ''),
'created_at': str(now),
'status': 'pending',
}
table.put_item(Item=item)
print(f'[agent-runner] Registered new user (pending): {actor_id}')
return item
def update_user_status(actor_id: str, name: str, status: str) -> None:
table_name = os.environ.get('USERS_TABLE_NAME', '')
if not table_name:
return
table = get_ddb().Table(table_name)
table.update_item(
Key={'actor_id': actor_id},
UpdateExpression='SET display_name = :n, #s = :s',
ExpressionAttributeNames={'#s': 'status'},
ExpressionAttributeValues={':n': name, ':s': status},
)
def send_telegram_direct(chat_id: str, token: str, text: str) -> None:
url = f'https://api.telegram.org/bot{token}/sendMessage'
data = json.dumps({'chat_id': chat_id, 'text': text}).encode()
req = urllib.request.Request(url, data=data, headers={'Content-Type': 'application/json'})
urllib.request.urlopen(req, timeout=10)
def get_or_create_session(actor_id: str) -> str:
"""Look up active session for actor, or create a new one."""
table = get_ddb().Table(os.environ['SESSION_TABLE_NAME'])
response = table.get_item(Key={'actor_id': actor_id})
item = response.get('Item')
now = int(time.time())
ttl_8hr = now + (8 * 3600)
if item and item.get('ttl', 0) > now:
# Active session exists — extend TTL
table.update_item(
Key={'actor_id': actor_id},
UpdateExpression='SET #ttl = :ttl',
ExpressionAttributeNames={'#ttl': 'ttl'},
ExpressionAttributeValues={':ttl': ttl_8hr},
)
return item['session_id']
# Create new session
session_id = str(uuid.uuid4())
table.put_item(Item={
'actor_id': actor_id,
'session_id': session_id,
'created_at': str(now),
'ttl': ttl_8hr,
})
return session_id
def handler(event, context):
# ── Parse SQS records (FIFO — all from same actor) ───────────────────
records = []
for record in event.get('Records', []):
try:
records.append(json.loads(record['body']))
except (json.JSONDecodeError, KeyError):
continue
if not records:
return
first = records[0]
channel = first.get('channel', 'telegram')
chat_id = first.get('chat_id', '')
actor_id = f"{channel}:{chat_id}"
# ── User registry ─────────────────────────────────────────────────────
from_info = first.get('messages', [{}])[0]
user_profile = get_or_create_user(actor_id, from_info)
# ── Onboarding gate ─────────────────────────────────────────────────────
table_name = os.environ.get('USERS_TABLE_NAME', '')
if table_name and user_profile.get('status', 'active') == 'pending':
raw_prompt = records[0]['messages'][0]['text'] if records else ''
is_name_msg = bool(raw_prompt and len(raw_prompt.strip()) < 50 and '?' not in raw_prompt)
if is_name_msg:
name = raw_prompt.strip()
update_user_status(actor_id, name=name, status='active')
user_profile['display_name'] = name
user_profile['status'] = 'active'
prompt = f"[System: User just registered with name '{name}'. Welcome them warmly and ask how you can help.]"
else:
bot_token_secret_arn = os.environ.get('TELEGRAM_BOT_TOKEN_SECRET_ARN', '')
bot_token = ''
if bot_token_secret_arn:
sm = boto3.client('secretsmanager', region_name='us-east-1')
bot_token = sm.get_secret_value(SecretId=bot_token_secret_arn)['SecretString']
send_telegram_direct(chat_id, bot_token, "Hi! I don't recognize you yet. What's your name?")
return
# ── Get or create AgentCore session ──────────────────────────────────
session_id = get_or_create_session(actor_id)
print(f"[agent-runner] actor={actor_id} session={session_id} user={user_profile.get('display_name', '')}")
# ── Bundle messages ───────────────────────────────────────────────────
if len(records) == 1:
prompt = records[0]['messages'][0]['text']
else:
lines = [
f"[{i+1}] {r['messages'][0]['text']}"
for i, r in enumerate(records)
]
prompt = f"You have {len(records)} queued messages:\n" + "\n".join(lines)
# ── Build payload for AgentCore Runtime 1 ────────────────────────────
payload: dict[str, Any] = {
'prompt': prompt,
'actor_id': actor_id,
'session_id': session_id,
'user_profile': {
'display_name': user_profile.get('display_name', actor_id),
'telegram_username': user_profile.get('telegram_username', ''),
'google_email': user_profile.get('google_email', ''),
'allowed': user_profile.get('allowed', True),
},
'channel_adapter': {
'type': channel,
'target_id': str(chat_id),
'bot_token_secret_arn': os.environ.get('TELEGRAM_BOT_TOKEN_SECRET_ARN', ''),
},
}
# ── Invoke AgentCore Runtime 1 ────────────────────────────────────────
runtime_arn = os.environ.get('RUNTIME_1_ARN', '')
if not runtime_arn or runtime_arn == 'PLACEHOLDER_SET_AFTER_RUNTIME_DEPLOY':
print(f"[agent-runner] RUNTIME_1_ARN not set — skipping AgentCore invoke")
print(f"[agent-runner] Would have sent: {json.dumps(payload)[:200]}")
return
client = get_agentcore()
response = client.invoke_agent_runtime(
agentRuntimeArn=runtime_arn,
runtimeSessionId=session_id,
payload=json.dumps(payload).encode(),
)
# Consume streaming response (agent delivers to Telegram via send_message tool)
for chunk in response.get('response', []):
pass # intentional no-op — agent handles delivery internally
print(f"[agent-runner] Completed session={session_id} actor={actor_id}")

View File

@@ -18,7 +18,7 @@
"validateOnSynth": false,
"assumeRoleArn": "arn:${AWS::Partition}:iam::495395224548:role/cdk-hnb659fds-deploy-role-495395224548-us-east-1",
"cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::495395224548:role/cdk-hnb659fds-cfn-exec-role-495395224548-us-east-1",
"stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-495395224548-us-east-1/2765094d543818b111d837ea62bad41260a47615c5b99bc608a58e99f24d5b85.json",
"stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-495395224548-us-east-1/fdf1ff81e9e0ded898f1c1d03a2bb8bbe0bbf63689426c24072f179b49b527c6.json",
"requiresBootstrapStackVersion": 6,
"bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version",
"additionalDependencies": [

File diff suppressed because one or more lines are too long

View File

@@ -87,6 +87,13 @@ class AgentClawStack extends cdk.Stack {
timeToLiveAttribute: 'ttl',
removalPolicy: cdk.RemovalPolicy.RETAIN,
});
// ── DynamoDB user registry ─────────────────────────────────────────────
const usersTable = new dynamodb.Table(this, 'UsersTable', {
tableName: 'agent-claw-users',
partitionKey: { name: 'actor_id', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
removalPolicy: cdk.RemovalPolicy.RETAIN,
});
// ── SQS FIFO message queue ─────────────────────────────────────────────
const messageQueue = new sqs.Queue(this, 'MessageQueue', {
queueName: 'agent-claw-messages.fifo',
@@ -129,6 +136,8 @@ class AgentClawStack extends cdk.Stack {
},
});
sessionTable.grantReadWriteData(agentRunnerFn);
usersTable.grantReadWriteData(agentRunnerFn);
agentRunnerFn.addEnvironment('USERS_TABLE_NAME', usersTable.tableName);
workspaceBucket.grantRead(agentRunnerFn);
botTokenSecret.grantRead(agentRunnerFn);
braveApiKeySecret.grantRead(agentRunnerFn);
@@ -185,18 +194,17 @@ class AgentClawStack extends cdk.Stack {
// TODO: Replace with L2 construct when aws-cdk-lib includes it stable
// For now, the runtime ARN must be created manually or via CLI after first synth
// and fed back as context param runtime1Arn.
// ── Outputs ────────────────────────────────────────────────────────────
// ── Google Workspace MCP ──────────────────────────────────────────────
// Secrets pre-populated after OAuth flow
const googleCredentialsSecret = secretsmanager.Secret.fromSecretNameV2(this, 'GoogleWorkspaceCredentials', 'agent-claw/google-workspace-credentials');
const googleOAuthClientSecret = secretsmanager.Secret.fromSecretNameV2(this, 'GoogleOAuthClient', 'agent-claw/google-oauth-client');
// workspace-mcp Lambda execution role (import existing — created during initial setup)
// NOTE (tech debt #3): workspaceMcpRole imported but not attached to workspaceMcpFn because
// fromFunctionName() returns an IFunction (no role config). Role was set at Lambda creation.
// To fully codify: delete the manual Lambda, let CDK create it with Code.fromBucket + role.
const _workspaceMcpRole = iam.Role.fromRoleName(this, 'WorkspaceMcpRole', 'agent-claw-workspace-mcp-role');
googleCredentialsSecret.grantRead(_workspaceMcpRole);
googleOAuthClientSecret.grantRead(_workspaceMcpRole);
// Grant workspace-mcp role read access to all per-user Google credential secrets
_workspaceMcpRole.addToPrincipalPolicy?.(new iam.PolicyStatement({
sid: 'PerUserGoogleCredentialsRead',
actions: ['secretsmanager:GetSecretValue'],
resources: [`arn:aws:secretsmanager:${this.region}:${this.account}:secret:agent-claw/google-credentials/*`],
}));
// workspace-mcp Lambda — import existing (created with zip + layer, no Docker)
const workspaceMcpFn = lambda.Function.fromFunctionName(this, 'WorkspaceMcp', 'agent-claw-workspace-mcp');
// Function URL — AWS_IAM auth (already created, reference for policy attachment)
@@ -209,18 +217,72 @@ class AgentClawStack extends cdk.Stack {
resources: [workspaceMcpFn.functionArn],
conditions: { StringEquals: { 'lambda:FunctionUrlAuthType': 'AWS_IAM' } },
}));
// Grant AgentCore execution role read access to Google OAuth client + per-user credentials
googleOAuthClientSecret.grantRead(runtime1Role);
runtime1Role.addToPolicy(new iam.PolicyStatement({
sid: 'PerUserGoogleCredentialsReadRuntime',
actions: ['secretsmanager:GetSecretValue'],
resources: [`arn:aws:secretsmanager:${this.region}:${this.account}:secret:agent-claw/google-credentials/*`],
}));
// Pass workspace_mcp MCP URL to agent-runner (informational)
agentRunnerFn.addEnvironment('WORKSPACE_MCP_URL', workspaceMcpMcpUrl);
// Grant AgentCore execution role read access to Google secrets
googleCredentialsSecret.grantRead(runtime1Role);
googleOAuthClientSecret.grantRead(runtime1Role);
// ── OAuth Handler Lambda ───────────────────────────────────────────────
const oauthHandlerFn = new lambda.Function(this, 'OAuthHandler', {
functionName: 'agent-claw-oauth-handler',
runtime: lambda.Runtime.PYTHON_3_12,
handler: 'handler.handler',
code: lambda.Code.fromAsset(path.join(__dirname, '../../src/lambdas/oauth-handler')),
timeout: cdk.Duration.seconds(30),
memorySize: 128,
environment: {
GOOGLE_OAUTH_CLIENT_SECRET_ARN: googleOAuthClientSecret.secretArn,
USERS_TABLE_NAME: usersTable.tableName,
// OAUTH_REDIRECT_URI set after API GW URL is known — injected via addEnvironment below
OAUTH_REDIRECT_URI: 'PLACEHOLDER',
},
});
googleOAuthClientSecret.grantRead(oauthHandlerFn);
usersTable.grantReadWriteData(oauthHandlerFn);
// Grant OAuth handler write access to per-user credential secrets
oauthHandlerFn.addToRolePolicy(new iam.PolicyStatement({
sid: 'PerUserGoogleCredentialsWrite',
actions: [
'secretsmanager:CreateSecret',
'secretsmanager:PutSecretValue',
'secretsmanager:GetSecretValue',
],
resources: [`arn:aws:secretsmanager:${this.region}:${this.account}:secret:agent-claw/google-credentials/*`],
}));
// Add OAuth routes to existing HTTP API
httpApi.addRoutes({
path: '/oauth/start',
methods: [apigatewayv2.HttpMethod.GET],
integration: new apigatewayv2integrations.HttpLambdaIntegration('OAuthStartIntegration', oauthHandlerFn),
});
httpApi.addRoutes({
path: '/oauth/callback',
methods: [apigatewayv2.HttpMethod.GET],
integration: new apigatewayv2integrations.HttpLambdaIntegration('OAuthCallbackIntegration', oauthHandlerFn),
});
// Set OAUTH_REDIRECT_URI now that we have the API URL
const oauthRedirectUri = `${httpApi.url}oauth/callback`;
oauthHandlerFn.addEnvironment('OAUTH_REDIRECT_URI', oauthRedirectUri);
// Pass OAuth start URL to AgentCore runtime so agent can generate connect links
const oauthStartUrl = `${httpApi.url}oauth/start`;
// NOTE: AgentCore runtime env vars are set in agentcore.json / agentcore deploy, not CDK.
// Output the URL so it can be manually set in agentcore.json OAUTH_START_URL.
// ── Outputs ────────────────────────────────────────────────────────────
new cdk.CfnOutput(this, 'WorkspaceMcpFunctionUrl', {
value: workspaceMcpFunctionUrl,
description: 'workspace-mcp Lambda Function URL (MCP endpoint for Gmail/Calendar)',
});
new cdk.CfnOutput(this, 'GoogleCredentialsSecretArn', {
value: googleCredentialsSecret.secretArn,
description: 'Google OAuth user credentials secret ARN',
new cdk.CfnOutput(this, 'OAuthStartUrl', {
value: oauthStartUrl,
description: 'Google OAuth start URL — set as OAUTH_START_URL in agentcore.json',
});
new cdk.CfnOutput(this, 'OAuthRedirectUri', {
value: oauthRedirectUri,
description: 'Google OAuth redirect URI — register in Google Cloud Console',
});
new cdk.CfnOutput(this, 'WebhookUrl', {
value: `${httpApi.url}telegram`,
@@ -234,6 +296,10 @@ class AgentClawStack extends cdk.Stack {
value: sessionTable.tableName,
description: 'DynamoDB table for session mapping',
});
new cdk.CfnOutput(this, 'UsersTableName', {
value: usersTable.tableName,
description: 'DynamoDB user registry table',
});
new cdk.CfnOutput(this, 'MessageQueueUrl', {
value: messageQueue.queueUrl,
description: 'SQS FIFO queue for incoming messages',

View File

@@ -186,26 +186,22 @@ export class AgentClawStack extends cdk.Stack {
// For now, the runtime ARN must be created manually or via CLI after first synth
// and fed back as context param runtime1Arn.
// ── Outputs ────────────────────────────────────────────────────────────
// ── Google Workspace MCP ──────────────────────────────────────────────
// Secrets pre-populated after OAuth flow
const googleCredentialsSecret = secretsmanager.Secret.fromSecretNameV2(
this, 'GoogleWorkspaceCredentials', 'agent-claw/google-workspace-credentials'
);
const googleOAuthClientSecret = secretsmanager.Secret.fromSecretNameV2(
this, 'GoogleOAuthClient', 'agent-claw/google-oauth-client'
);
// workspace-mcp Lambda execution role (import existing — created during initial setup)
// NOTE (tech debt #3): workspaceMcpRole imported but not attached to workspaceMcpFn because
// fromFunctionName() returns an IFunction (no role config). Role was set at Lambda creation.
// To fully codify: delete the manual Lambda, let CDK create it with Code.fromBucket + role.
const _workspaceMcpRole = iam.Role.fromRoleName(
this, 'WorkspaceMcpRole', 'agent-claw-workspace-mcp-role'
);
googleCredentialsSecret.grantRead(_workspaceMcpRole);
googleOAuthClientSecret.grantRead(_workspaceMcpRole);
// Grant workspace-mcp role read access to all per-user Google credential secrets
(_workspaceMcpRole as iam.Role).addToPrincipalPolicy?.(new iam.PolicyStatement({
sid: 'PerUserGoogleCredentialsRead',
actions: ['secretsmanager:GetSecretValue'],
resources: [`arn:aws:secretsmanager:${this.region}:${this.account}:secret:agent-claw/google-credentials/*`],
}));
// workspace-mcp Lambda — import existing (created with zip + layer, no Docker)
const workspaceMcpFn = lambda.Function.fromFunctionName(
@@ -224,20 +220,83 @@ export class AgentClawStack extends cdk.Stack {
conditions: { StringEquals: { 'lambda:FunctionUrlAuthType': 'AWS_IAM' } },
}));
// Grant AgentCore execution role read access to Google OAuth client + per-user credentials
googleOAuthClientSecret.grantRead(runtime1Role);
runtime1Role.addToPolicy(new iam.PolicyStatement({
sid: 'PerUserGoogleCredentialsReadRuntime',
actions: ['secretsmanager:GetSecretValue'],
resources: [`arn:aws:secretsmanager:${this.region}:${this.account}:secret:agent-claw/google-credentials/*`],
}));
// Pass workspace_mcp MCP URL to agent-runner (informational)
agentRunnerFn.addEnvironment('WORKSPACE_MCP_URL', workspaceMcpMcpUrl);
// Grant AgentCore execution role read access to Google secrets
googleCredentialsSecret.grantRead(runtime1Role);
googleOAuthClientSecret.grantRead(runtime1Role);
// ── OAuth Handler Lambda ───────────────────────────────────────────────
const oauthHandlerFn = new lambda.Function(this, 'OAuthHandler', {
functionName: 'agent-claw-oauth-handler',
runtime: lambda.Runtime.PYTHON_3_12,
handler: 'handler.handler',
code: lambda.Code.fromAsset(path.join(__dirname, '../../src/lambdas/oauth-handler')),
timeout: cdk.Duration.seconds(30),
memorySize: 128,
environment: {
GOOGLE_OAUTH_CLIENT_SECRET_ARN: googleOAuthClientSecret.secretArn,
USERS_TABLE_NAME: usersTable.tableName,
// OAUTH_REDIRECT_URI set after API GW URL is known — injected via addEnvironment below
OAUTH_REDIRECT_URI: 'PLACEHOLDER',
},
});
googleOAuthClientSecret.grantRead(oauthHandlerFn);
usersTable.grantReadWriteData(oauthHandlerFn);
// Grant OAuth handler write access to per-user credential secrets
oauthHandlerFn.addToRolePolicy(new iam.PolicyStatement({
sid: 'PerUserGoogleCredentialsWrite',
actions: [
'secretsmanager:CreateSecret',
'secretsmanager:PutSecretValue',
'secretsmanager:GetSecretValue',
],
resources: [`arn:aws:secretsmanager:${this.region}:${this.account}:secret:agent-claw/google-credentials/*`],
}));
// Add OAuth routes to existing HTTP API
httpApi.addRoutes({
path: '/oauth/start',
methods: [apigatewayv2.HttpMethod.GET],
integration: new apigatewayv2integrations.HttpLambdaIntegration(
'OAuthStartIntegration', oauthHandlerFn
),
});
httpApi.addRoutes({
path: '/oauth/callback',
methods: [apigatewayv2.HttpMethod.GET],
integration: new apigatewayv2integrations.HttpLambdaIntegration(
'OAuthCallbackIntegration', oauthHandlerFn
),
});
// Set OAUTH_REDIRECT_URI now that we have the API URL
const oauthRedirectUri = `${httpApi.url}oauth/callback`;
oauthHandlerFn.addEnvironment('OAUTH_REDIRECT_URI', oauthRedirectUri);
// Pass OAuth start URL to AgentCore runtime so agent can generate connect links
const oauthStartUrl = `${httpApi.url}oauth/start`;
// NOTE: AgentCore runtime env vars are set in agentcore.json / agentcore deploy, not CDK.
// Output the URL so it can be manually set in agentcore.json OAUTH_START_URL.
// ── Outputs ────────────────────────────────────────────────────────────
new cdk.CfnOutput(this, 'WorkspaceMcpFunctionUrl', {
value: workspaceMcpFunctionUrl,
description: 'workspace-mcp Lambda Function URL (MCP endpoint for Gmail/Calendar)',
});
new cdk.CfnOutput(this, 'GoogleCredentialsSecretArn', {
value: googleCredentialsSecret.secretArn,
description: 'Google OAuth user credentials secret ARN',
new cdk.CfnOutput(this, 'OAuthStartUrl', {
value: oauthStartUrl,
description: 'Google OAuth start URL — set as OAUTH_START_URL in agentcore.json',
});
new cdk.CfnOutput(this, 'OAuthRedirectUri', {
value: oauthRedirectUri,
description: 'Google OAuth redirect URI — register in Google Cloud Console',
});
new cdk.CfnOutput(this, 'WebhookUrl', {