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:
@@ -15,7 +15,10 @@
|
|||||||
"codeLocation": "app/agent_claw_main/",
|
"codeLocation": "app/agent_claw_main/",
|
||||||
"runtimeVersion": "PYTHON_3_14",
|
"runtimeVersion": "PYTHON_3_14",
|
||||||
"networkMode": "PUBLIC",
|
"networkMode": "PUBLIC",
|
||||||
"protocol": "HTTP"
|
"protocol": "HTTP",
|
||||||
|
"environmentVariables": {
|
||||||
|
"OAUTH_START_URL": "https://sptejrymri.execute-api.us-east-1.amazonaws.com/oauth/start"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"memories": [
|
"memories": [
|
||||||
|
|||||||
@@ -23,12 +23,14 @@ import boto3
|
|||||||
from urllib.parse import urlparse as _urlparse
|
from urllib.parse import urlparse as _urlparse
|
||||||
|
|
||||||
WORKSPACE_MCP_URL = 'https://25hugrzw4uwtueeg77jsmft6lq0wunmd.lambda-url.us-east-1.on.aws/mcp'
|
WORKSPACE_MCP_URL = 'https://25hugrzw4uwtueeg77jsmft6lq0wunmd.lambda-url.us-east-1.on.aws/mcp'
|
||||||
|
OAUTH_START_URL = os.environ.get('OAUTH_START_URL', '')
|
||||||
|
|
||||||
|
|
||||||
class _SigV4HttpxAuth(httpx.Auth):
|
class _SigV4HttpxAuth(httpx.Auth):
|
||||||
"""SigV4 auth for Lambda Function URL with AWS_IAM."""
|
"""SigV4 auth for Lambda Function URL with AWS_IAM, plus X-Actor-Id header."""
|
||||||
def __init__(self, region: str = 'us-east-1'):
|
def __init__(self, region: str = 'us-east-1', actor_id: str = ''):
|
||||||
self._region = region
|
self._region = region
|
||||||
|
self._actor_id = actor_id
|
||||||
|
|
||||||
def auth_flow(self, request):
|
def auth_flow(self, request):
|
||||||
creds = boto3.Session().get_credentials().get_frozen_credentials()
|
creds = boto3.Session().get_credentials().get_frozen_credentials()
|
||||||
@@ -46,6 +48,8 @@ class _SigV4HttpxAuth(httpx.Auth):
|
|||||||
botocore.auth.SigV4Auth(creds, 'lambda', self._region).add_auth(aws_req)
|
botocore.auth.SigV4Auth(creds, 'lambda', self._region).add_auth(aws_req)
|
||||||
for k, v in aws_req.headers.items():
|
for k, v in aws_req.headers.items():
|
||||||
request.headers[k] = v
|
request.headers[k] = v
|
||||||
|
if self._actor_id:
|
||||||
|
request.headers['x-actor-id'] = self._actor_id
|
||||||
yield request
|
yield request
|
||||||
from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig
|
from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig
|
||||||
from bedrock_agentcore.memory.integrations.strands.session_manager import AgentCoreMemorySessionManager
|
from bedrock_agentcore.memory.integrations.strands.session_manager import AgentCoreMemorySessionManager
|
||||||
@@ -91,11 +95,31 @@ def write_workspace_file(path: str, content: str) -> str:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def connect_google_account() -> str:
|
||||||
|
"""Generate a Google OAuth authorization URL for the current user to connect their Google account.
|
||||||
|
Use this when the user wants to connect Google Workspace (Gmail, Calendar, Drive, etc.)
|
||||||
|
or when Google tools fail due to missing credentials."""
|
||||||
|
if not OAUTH_START_URL:
|
||||||
|
return 'Google OAuth is not configured. Set OAUTH_START_URL environment variable.'
|
||||||
|
# actor_id is injected into the tool's closure via _current_actor_id module-level var
|
||||||
|
actor_id = _current_actor_id
|
||||||
|
if not actor_id:
|
||||||
|
return 'Cannot determine actor_id for OAuth flow.'
|
||||||
|
url = f'{OAUTH_START_URL}?actor_id={actor_id}'
|
||||||
|
return f'Please open this URL to connect your Google account:\n{url}\n\nAfter authorizing, Google Workspace tools (Gmail, Calendar, Drive) will be available.'
|
||||||
|
|
||||||
|
|
||||||
# ── Entrypoint ────────────────────────────────────────────────────────────
|
# ── Entrypoint ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Module-level actor_id for tool closures (set per-invocation)
|
||||||
|
_current_actor_id: str = ''
|
||||||
|
|
||||||
|
|
||||||
@app.entrypoint
|
@app.entrypoint
|
||||||
def main(payload: dict, context) -> dict:
|
def main(payload: dict, context) -> dict:
|
||||||
"""Handle an invocation from agent-runner Lambda."""
|
"""Handle an invocation from agent-runner Lambda."""
|
||||||
|
global _current_actor_id
|
||||||
|
|
||||||
# Set up channel adapter
|
# Set up channel adapter
|
||||||
adapter_config = payload.get('channel_adapter', {})
|
adapter_config = payload.get('channel_adapter', {})
|
||||||
@@ -129,6 +153,7 @@ def main(payload: dict, context) -> dict:
|
|||||||
MEMORY_ID = 'agentclaw_AgentClawMemory-i7Csf776AH'
|
MEMORY_ID = 'agentclaw_AgentClawMemory-i7Csf776AH'
|
||||||
actor_id = payload.get('actor_id', adapter_config.get('target_id', 'default'))
|
actor_id = payload.get('actor_id', adapter_config.get('target_id', 'default'))
|
||||||
session_id = payload.get('session_id', f'session-{actor_id}')
|
session_id = payload.get('session_id', f'session-{actor_id}')
|
||||||
|
_current_actor_id = actor_id
|
||||||
|
|
||||||
memory_config = AgentCoreMemoryConfig(
|
memory_config = AgentCoreMemoryConfig(
|
||||||
memory_id=MEMORY_ID,
|
memory_id=MEMORY_ID,
|
||||||
@@ -146,9 +171,14 @@ def main(payload: dict, context) -> dict:
|
|||||||
if user_profile:
|
if user_profile:
|
||||||
name = user_profile.get('display_name', '')
|
name = user_profile.get('display_name', '')
|
||||||
username = user_profile.get('telegram_username', '')
|
username = user_profile.get('telegram_username', '')
|
||||||
|
google_email = user_profile.get('google_email', '')
|
||||||
user_context = f'Name: {name}'
|
user_context = f'Name: {name}'
|
||||||
if username:
|
if username:
|
||||||
user_context += f'\nTelegram username: @{username}'
|
user_context += f'\nTelegram username: @{username}'
|
||||||
|
if google_email:
|
||||||
|
user_context += f'\nGoogle account: {google_email}'
|
||||||
|
else:
|
||||||
|
user_context += '\nGoogle account: not connected (use connect_google_account tool to connect)'
|
||||||
system_prompt = build_system_prompt(user_context=user_context, actor_id=actor_id)
|
system_prompt = build_system_prompt(user_context=user_context, actor_id=actor_id)
|
||||||
|
|
||||||
# Model: claude-sonnet-4-6 via cross-region inference
|
# Model: claude-sonnet-4-6 via cross-region inference
|
||||||
@@ -158,7 +188,7 @@ def main(payload: dict, context) -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
base_tools = [send_message, web_search, web_fetch, read_workspace_file, write_workspace_file,
|
base_tools = [send_message, web_search, web_fetch, read_workspace_file, write_workspace_file,
|
||||||
_code_interpreter.code_interpreter, home_assistant]
|
_code_interpreter.code_interpreter, home_assistant, connect_google_account]
|
||||||
|
|
||||||
def _run_agent(tools):
|
def _run_agent(tools):
|
||||||
agent = Agent(
|
agent = Agent(
|
||||||
@@ -170,14 +200,19 @@ def main(payload: dict, context) -> dict:
|
|||||||
return agent(payload.get('prompt', ''))
|
return agent(payload.get('prompt', ''))
|
||||||
|
|
||||||
workspace_mcp_client = MCPClient(
|
workspace_mcp_client = MCPClient(
|
||||||
lambda: streamablehttp_client(WORKSPACE_MCP_URL, timeout=20, auth=_SigV4HttpxAuth())
|
lambda: streamablehttp_client(WORKSPACE_MCP_URL, timeout=20, auth=_SigV4HttpxAuth(actor_id=actor_id))
|
||||||
)
|
)
|
||||||
workspace_tools = []
|
workspace_tools = []
|
||||||
try:
|
google_email = user_profile.get('google_email', '')
|
||||||
with workspace_mcp_client:
|
if google_email:
|
||||||
workspace_tools = workspace_mcp_client.list_tools_sync()
|
# Only attempt workspace-mcp if user has connected Google
|
||||||
except Exception as e:
|
try:
|
||||||
print(f'[main] workspace_mcp unavailable ({type(e).__name__}) — continuing without it')
|
with workspace_mcp_client:
|
||||||
|
workspace_tools = workspace_mcp_client.list_tools_sync()
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[main] workspace_mcp unavailable ({type(e).__name__}) — continuing without it')
|
||||||
|
else:
|
||||||
|
print(f'[main] actor={actor_id} has no google_email — skipping workspace_mcp')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = _run_agent(base_tools + list(workspace_tools))
|
result = _run_agent(base_tools + list(workspace_tools))
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -16,31 +16,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"7053cd1618f5f520a7aac409588128f920d8fe76791c1dbcc65610454d1a5387": {
|
"6f6fdf79f33a947f3e50ffd783a72d04ab5f29ba299a5d51b3ecd2c2eb311370": {
|
||||||
"displayName": "AgentRunner/Code",
|
"displayName": "AgentRunner/Code",
|
||||||
"source": {
|
"source": {
|
||||||
"path": "asset.7053cd1618f5f520a7aac409588128f920d8fe76791c1dbcc65610454d1a5387",
|
"path": "asset.6f6fdf79f33a947f3e50ffd783a72d04ab5f29ba299a5d51b3ecd2c2eb311370",
|
||||||
"packaging": "zip"
|
"packaging": "zip"
|
||||||
},
|
},
|
||||||
"destinations": {
|
"destinations": {
|
||||||
"495395224548-us-east-1-63ace858": {
|
"495395224548-us-east-1-ab491e35": {
|
||||||
"bucketName": "cdk-hnb659fds-assets-495395224548-us-east-1",
|
"bucketName": "cdk-hnb659fds-assets-495395224548-us-east-1",
|
||||||
"objectKey": "7053cd1618f5f520a7aac409588128f920d8fe76791c1dbcc65610454d1a5387.zip",
|
"objectKey": "6f6fdf79f33a947f3e50ffd783a72d04ab5f29ba299a5d51b3ecd2c2eb311370.zip",
|
||||||
"region": "us-east-1",
|
"region": "us-east-1",
|
||||||
"assumeRoleArn": "arn:${AWS::Partition}:iam::495395224548:role/cdk-hnb659fds-file-publishing-role-495395224548-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",
|
"displayName": "AgentClawStack Template",
|
||||||
"source": {
|
"source": {
|
||||||
"path": "AgentClawStack.template.json",
|
"path": "AgentClawStack.template.json",
|
||||||
"packaging": "file"
|
"packaging": "file"
|
||||||
},
|
},
|
||||||
"destinations": {
|
"destinations": {
|
||||||
"495395224548-us-east-1-b10aaf8d": {
|
"495395224548-us-east-1-9bba4277": {
|
||||||
"bucketName": "cdk-hnb659fds-assets-495395224548-us-east-1",
|
"bucketName": "cdk-hnb659fds-assets-495395224548-us-east-1",
|
||||||
"objectKey": "2765094d543818b111d837ea62bad41260a47615c5b99bc608a58e99f24d5b85.json",
|
"objectKey": "fdf1ff81e9e0ded898f1c1d03a2bb8bbe0bbf63689426c24072f179b49b527c6.json",
|
||||||
"region": "us-east-1",
|
"region": "us-east-1",
|
||||||
"assumeRoleArn": "arn:${AWS::Partition}:iam::495395224548:role/cdk-hnb659fds-file-publishing-role-495395224548-us-east-1"
|
"assumeRoleArn": "arn:${AWS::Partition}:iam::495395224548:role/cdk-hnb659fds-file-publishing-role-495395224548-us-east-1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,21 +32,35 @@
|
|||||||
{
|
{
|
||||||
"type": "aws:cdk:creationStack",
|
"type": "aws:cdk:creationStack",
|
||||||
"data": [
|
"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)",
|
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
|
||||||
"...node internals, ts-node, ts-node, ts-node..."
|
"...node internals, ts-node, ts-node, ts-node..."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"/AgentClawStack/GoogleCredentialsSecretArn": [
|
"/AgentClawStack/OAuthStartUrl": [
|
||||||
{
|
{
|
||||||
"type": "aws:cdk:logicalId",
|
"type": "aws:cdk:logicalId",
|
||||||
"data": "GoogleCredentialsSecretArn"
|
"data": "OAuthStartUrl"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "aws:cdk:creationStack",
|
"type": "aws:cdk:creationStack",
|
||||||
"data": [
|
"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)",
|
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
|
||||||
"...node internals, ts-node, ts-node, ts-node..."
|
"...node internals, ts-node, ts-node, ts-node..."
|
||||||
]
|
]
|
||||||
@@ -60,7 +74,7 @@
|
|||||||
{
|
{
|
||||||
"type": "aws:cdk:creationStack",
|
"type": "aws:cdk:creationStack",
|
||||||
"data": [
|
"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)",
|
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
|
||||||
"...node internals, ts-node, ts-node, ts-node..."
|
"...node internals, ts-node, ts-node, ts-node..."
|
||||||
]
|
]
|
||||||
@@ -74,7 +88,7 @@
|
|||||||
{
|
{
|
||||||
"type": "aws:cdk:creationStack",
|
"type": "aws:cdk:creationStack",
|
||||||
"data": [
|
"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)",
|
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
|
||||||
"...node internals, ts-node, ts-node, ts-node..."
|
"...node internals, ts-node, ts-node, ts-node..."
|
||||||
]
|
]
|
||||||
@@ -88,7 +102,7 @@
|
|||||||
{
|
{
|
||||||
"type": "aws:cdk:creationStack",
|
"type": "aws:cdk:creationStack",
|
||||||
"data": [
|
"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)",
|
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
|
||||||
"...node internals, ts-node, ts-node, ts-node..."
|
"...node internals, ts-node, ts-node, ts-node..."
|
||||||
]
|
]
|
||||||
@@ -102,7 +116,7 @@
|
|||||||
{
|
{
|
||||||
"type": "aws:cdk:creationStack",
|
"type": "aws:cdk:creationStack",
|
||||||
"data": [
|
"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)",
|
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
|
||||||
"...node internals, ts-node, ts-node, ts-node..."
|
"...node internals, ts-node, ts-node, ts-node..."
|
||||||
]
|
]
|
||||||
@@ -116,7 +130,7 @@
|
|||||||
{
|
{
|
||||||
"type": "aws:cdk:creationStack",
|
"type": "aws:cdk:creationStack",
|
||||||
"data": [
|
"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)",
|
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
|
||||||
"...node internals, ts-node, ts-node, ts-node..."
|
"...node internals, ts-node, ts-node, ts-node..."
|
||||||
]
|
]
|
||||||
@@ -130,7 +144,7 @@
|
|||||||
{
|
{
|
||||||
"type": "aws:cdk:creationStack",
|
"type": "aws:cdk:creationStack",
|
||||||
"data": [
|
"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)",
|
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
|
||||||
"...node internals, ts-node, ts-node, ts-node..."
|
"...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": [
|
"/AgentClawStack/CDKMetadata/Default": [
|
||||||
{
|
{
|
||||||
"type": "aws:cdk:logicalId",
|
"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": [
|
"/AgentClawStack/Runtime1Role/DefaultPolicy/Resource": [
|
||||||
{
|
{
|
||||||
"type": "aws:cdk:logicalId",
|
"type": "aws:cdk:logicalId",
|
||||||
@@ -398,7 +495,22 @@
|
|||||||
"type": "aws:cdk:creationStack",
|
"type": "aws:cdk:creationStack",
|
||||||
"data": [
|
"data": [
|
||||||
"...SecretBase.grantRead in aws-cdk-lib...",
|
"...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)",
|
"<anonymous> (/Users/daniel/agent-claw/cdk/bin/agent-claw.ts:8:1)",
|
||||||
"...node internals, ts-node, ts-node, ts-node..."
|
"...node internals, ts-node, ts-node, ts-node..."
|
||||||
]
|
]
|
||||||
@@ -450,5 +562,54 @@
|
|||||||
"...node internals, ts-node, ts-node, ts-node..."
|
"...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..."
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -387,7 +387,7 @@
|
|||||||
"Properties": {
|
"Properties": {
|
||||||
"Code": {
|
"Code": {
|
||||||
"S3Bucket": "cdk-hnb659fds-assets-495395224548-us-east-1",
|
"S3Bucket": "cdk-hnb659fds-assets-495395224548-us-east-1",
|
||||||
"S3Key": "7053cd1618f5f520a7aac409588128f920d8fe76791c1dbcc65610454d1a5387.zip"
|
"S3Key": "6f6fdf79f33a947f3e50ffd783a72d04ab5f29ba299a5d51b3ecd2c2eb311370.zip"
|
||||||
},
|
},
|
||||||
"Environment": {
|
"Environment": {
|
||||||
"Variables": {
|
"Variables": {
|
||||||
@@ -423,7 +423,7 @@
|
|||||||
],
|
],
|
||||||
"Metadata": {
|
"Metadata": {
|
||||||
"aws:cdk:path": "AgentClawStack/AgentRunner/Resource",
|
"aws:cdk:path": "AgentClawStack/AgentRunner/Resource",
|
||||||
"aws:asset:path": "asset.7053cd1618f5f520a7aac409588128f920d8fe76791c1dbcc65610454d1a5387",
|
"aws:asset:path": "asset.6f6fdf79f33a947f3e50ffd783a72d04ab5f29ba299a5d51b3ecd2c2eb311370",
|
||||||
"aws:asset:is-bundled": false,
|
"aws:asset:is-bundled": false,
|
||||||
"aws:asset:property": "Code"
|
"aws:asset:property": "Code"
|
||||||
}
|
}
|
||||||
@@ -545,6 +545,156 @@
|
|||||||
"aws:cdk:path": "AgentClawStack/WebhookApi/POST--telegram/Resource"
|
"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": {
|
"Runtime1RoleA7A82078": {
|
||||||
"Type": "AWS::IAM::Role",
|
"Type": "AWS::IAM::Role",
|
||||||
"Properties": {
|
"Properties": {
|
||||||
@@ -674,29 +824,16 @@
|
|||||||
{
|
{
|
||||||
"Ref": "AWS::Partition"
|
"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": [
|
"Action": "secretsmanager:GetSecretValue",
|
||||||
"secretsmanager:GetSecretValue",
|
|
||||||
"secretsmanager:DescribeSecret"
|
|
||||||
],
|
|
||||||
"Effect": "Allow",
|
"Effect": "Allow",
|
||||||
"Resource": {
|
"Resource": "arn:aws:secretsmanager:us-east-1:495395224548:secret:agent-claw/google-credentials/*",
|
||||||
"Fn::Join": [
|
"Sid": "PerUserGoogleCredentialsReadRuntime"
|
||||||
"",
|
|
||||||
[
|
|
||||||
"arn:",
|
|
||||||
{
|
|
||||||
"Ref": "AWS::Partition"
|
|
||||||
},
|
|
||||||
":secretsmanager:us-east-1:495395224548:secret:agent-claw/google-oauth-client-??????"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"Version": "2012-10-17"
|
"Version": "2012-10-17"
|
||||||
@@ -731,11 +868,68 @@
|
|||||||
{
|
{
|
||||||
"Ref": "AWS::Partition"
|
"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": [
|
"Action": [
|
||||||
"secretsmanager:GetSecretValue",
|
"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"
|
"Version": "2012-10-17"
|
||||||
},
|
},
|
||||||
"PolicyName": "WorkspaceMcpRolePolicy5B8B0072",
|
"PolicyName": "OAuthHandlerServiceRoleDefaultPolicy69D90416",
|
||||||
"Roles": [
|
"Roles": [
|
||||||
"agent-claw-workspace-mcp-role"
|
{
|
||||||
|
"Ref": "OAuthHandlerServiceRole9CDCCF9E"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Metadata": {
|
"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": {
|
"CDKMetadata": {
|
||||||
@@ -782,17 +1091,40 @@
|
|||||||
"Description": "workspace-mcp Lambda Function URL (MCP endpoint for Gmail/Calendar)",
|
"Description": "workspace-mcp Lambda Function URL (MCP endpoint for Gmail/Calendar)",
|
||||||
"Value": "https://25hugrzw4uwtueeg77jsmft6lq0wunmd.lambda-url.us-east-1.on.aws"
|
"Value": "https://25hugrzw4uwtueeg77jsmft6lq0wunmd.lambda-url.us-east-1.on.aws"
|
||||||
},
|
},
|
||||||
"GoogleCredentialsSecretArn": {
|
"OAuthStartUrl": {
|
||||||
"Description": "Google OAuth user credentials secret ARN",
|
"Description": "Google OAuth start URL — set as OAUTH_START_URL in agentcore.json",
|
||||||
"Value": {
|
"Value": {
|
||||||
"Fn::Join": [
|
"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"
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
boto3>=1.34.0
|
||||||
@@ -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>'
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
boto3>=1.34.0
|
||||||
@@ -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}")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
boto3>=1.34.0
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"validateOnSynth": false,
|
"validateOnSynth": false,
|
||||||
"assumeRoleArn": "arn:${AWS::Partition}:iam::495395224548:role/cdk-hnb659fds-deploy-role-495395224548-us-east-1",
|
"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",
|
"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,
|
"requiresBootstrapStackVersion": 6,
|
||||||
"bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version",
|
"bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version",
|
||||||
"additionalDependencies": [
|
"additionalDependencies": [
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -87,6 +87,13 @@ class AgentClawStack extends cdk.Stack {
|
|||||||
timeToLiveAttribute: 'ttl',
|
timeToLiveAttribute: 'ttl',
|
||||||
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
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 ─────────────────────────────────────────────
|
// ── SQS FIFO message queue ─────────────────────────────────────────────
|
||||||
const messageQueue = new sqs.Queue(this, 'MessageQueue', {
|
const messageQueue = new sqs.Queue(this, 'MessageQueue', {
|
||||||
queueName: 'agent-claw-messages.fifo',
|
queueName: 'agent-claw-messages.fifo',
|
||||||
@@ -129,6 +136,8 @@ class AgentClawStack extends cdk.Stack {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
sessionTable.grantReadWriteData(agentRunnerFn);
|
sessionTable.grantReadWriteData(agentRunnerFn);
|
||||||
|
usersTable.grantReadWriteData(agentRunnerFn);
|
||||||
|
agentRunnerFn.addEnvironment('USERS_TABLE_NAME', usersTable.tableName);
|
||||||
workspaceBucket.grantRead(agentRunnerFn);
|
workspaceBucket.grantRead(agentRunnerFn);
|
||||||
botTokenSecret.grantRead(agentRunnerFn);
|
botTokenSecret.grantRead(agentRunnerFn);
|
||||||
braveApiKeySecret.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
|
// 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
|
// For now, the runtime ARN must be created manually or via CLI after first synth
|
||||||
// and fed back as context param runtime1Arn.
|
// and fed back as context param runtime1Arn.
|
||||||
// ── Outputs ────────────────────────────────────────────────────────────
|
|
||||||
// ── Google Workspace MCP ──────────────────────────────────────────────
|
// ── 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');
|
const googleOAuthClientSecret = secretsmanager.Secret.fromSecretNameV2(this, 'GoogleOAuthClient', 'agent-claw/google-oauth-client');
|
||||||
// workspace-mcp Lambda execution role (import existing — created during initial setup)
|
// 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');
|
const _workspaceMcpRole = iam.Role.fromRoleName(this, 'WorkspaceMcpRole', 'agent-claw-workspace-mcp-role');
|
||||||
googleCredentialsSecret.grantRead(_workspaceMcpRole);
|
|
||||||
googleOAuthClientSecret.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)
|
// workspace-mcp Lambda — import existing (created with zip + layer, no Docker)
|
||||||
const workspaceMcpFn = lambda.Function.fromFunctionName(this, 'WorkspaceMcp', 'agent-claw-workspace-mcp');
|
const workspaceMcpFn = lambda.Function.fromFunctionName(this, 'WorkspaceMcp', 'agent-claw-workspace-mcp');
|
||||||
// Function URL — AWS_IAM auth (already created, reference for policy attachment)
|
// Function URL — AWS_IAM auth (already created, reference for policy attachment)
|
||||||
@@ -209,18 +217,72 @@ class AgentClawStack extends cdk.Stack {
|
|||||||
resources: [workspaceMcpFn.functionArn],
|
resources: [workspaceMcpFn.functionArn],
|
||||||
conditions: { StringEquals: { 'lambda:FunctionUrlAuthType': 'AWS_IAM' } },
|
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)
|
// Pass workspace_mcp MCP URL to agent-runner (informational)
|
||||||
agentRunnerFn.addEnvironment('WORKSPACE_MCP_URL', workspaceMcpMcpUrl);
|
agentRunnerFn.addEnvironment('WORKSPACE_MCP_URL', workspaceMcpMcpUrl);
|
||||||
// Grant AgentCore execution role read access to Google secrets
|
// ── OAuth Handler Lambda ───────────────────────────────────────────────
|
||||||
googleCredentialsSecret.grantRead(runtime1Role);
|
const oauthHandlerFn = new lambda.Function(this, 'OAuthHandler', {
|
||||||
googleOAuthClientSecret.grantRead(runtime1Role);
|
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', {
|
new cdk.CfnOutput(this, 'WorkspaceMcpFunctionUrl', {
|
||||||
value: workspaceMcpFunctionUrl,
|
value: workspaceMcpFunctionUrl,
|
||||||
description: 'workspace-mcp Lambda Function URL (MCP endpoint for Gmail/Calendar)',
|
description: 'workspace-mcp Lambda Function URL (MCP endpoint for Gmail/Calendar)',
|
||||||
});
|
});
|
||||||
new cdk.CfnOutput(this, 'GoogleCredentialsSecretArn', {
|
new cdk.CfnOutput(this, 'OAuthStartUrl', {
|
||||||
value: googleCredentialsSecret.secretArn,
|
value: oauthStartUrl,
|
||||||
description: 'Google OAuth user credentials secret ARN',
|
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', {
|
new cdk.CfnOutput(this, 'WebhookUrl', {
|
||||||
value: `${httpApi.url}telegram`,
|
value: `${httpApi.url}telegram`,
|
||||||
@@ -234,6 +296,10 @@ class AgentClawStack extends cdk.Stack {
|
|||||||
value: sessionTable.tableName,
|
value: sessionTable.tableName,
|
||||||
description: 'DynamoDB table for session mapping',
|
description: 'DynamoDB table for session mapping',
|
||||||
});
|
});
|
||||||
|
new cdk.CfnOutput(this, 'UsersTableName', {
|
||||||
|
value: usersTable.tableName,
|
||||||
|
description: 'DynamoDB user registry table',
|
||||||
|
});
|
||||||
new cdk.CfnOutput(this, 'MessageQueueUrl', {
|
new cdk.CfnOutput(this, 'MessageQueueUrl', {
|
||||||
value: messageQueue.queueUrl,
|
value: messageQueue.queueUrl,
|
||||||
description: 'SQS FIFO queue for incoming messages',
|
description: 'SQS FIFO queue for incoming messages',
|
||||||
|
|||||||
@@ -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
|
// For now, the runtime ARN must be created manually or via CLI after first synth
|
||||||
// and fed back as context param runtime1Arn.
|
// and fed back as context param runtime1Arn.
|
||||||
|
|
||||||
// ── Outputs ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// ── Google Workspace MCP ──────────────────────────────────────────────
|
// ── 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(
|
const googleOAuthClientSecret = secretsmanager.Secret.fromSecretNameV2(
|
||||||
this, 'GoogleOAuthClient', 'agent-claw/google-oauth-client'
|
this, 'GoogleOAuthClient', 'agent-claw/google-oauth-client'
|
||||||
);
|
);
|
||||||
|
|
||||||
// workspace-mcp Lambda execution role (import existing — created during initial setup)
|
// 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(
|
const _workspaceMcpRole = iam.Role.fromRoleName(
|
||||||
this, 'WorkspaceMcpRole', 'agent-claw-workspace-mcp-role'
|
this, 'WorkspaceMcpRole', 'agent-claw-workspace-mcp-role'
|
||||||
);
|
);
|
||||||
googleCredentialsSecret.grantRead(_workspaceMcpRole);
|
|
||||||
googleOAuthClientSecret.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)
|
// workspace-mcp Lambda — import existing (created with zip + layer, no Docker)
|
||||||
const workspaceMcpFn = lambda.Function.fromFunctionName(
|
const workspaceMcpFn = lambda.Function.fromFunctionName(
|
||||||
@@ -224,20 +220,83 @@ export class AgentClawStack extends cdk.Stack {
|
|||||||
conditions: { StringEquals: { 'lambda:FunctionUrlAuthType': 'AWS_IAM' } },
|
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)
|
// Pass workspace_mcp MCP URL to agent-runner (informational)
|
||||||
agentRunnerFn.addEnvironment('WORKSPACE_MCP_URL', workspaceMcpMcpUrl);
|
agentRunnerFn.addEnvironment('WORKSPACE_MCP_URL', workspaceMcpMcpUrl);
|
||||||
|
|
||||||
// Grant AgentCore execution role read access to Google secrets
|
// ── OAuth Handler Lambda ───────────────────────────────────────────────
|
||||||
googleCredentialsSecret.grantRead(runtime1Role);
|
const oauthHandlerFn = new lambda.Function(this, 'OAuthHandler', {
|
||||||
googleOAuthClientSecret.grantRead(runtime1Role);
|
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', {
|
new cdk.CfnOutput(this, 'WorkspaceMcpFunctionUrl', {
|
||||||
value: workspaceMcpFunctionUrl,
|
value: workspaceMcpFunctionUrl,
|
||||||
description: 'workspace-mcp Lambda Function URL (MCP endpoint for Gmail/Calendar)',
|
description: 'workspace-mcp Lambda Function URL (MCP endpoint for Gmail/Calendar)',
|
||||||
});
|
});
|
||||||
new cdk.CfnOutput(this, 'GoogleCredentialsSecretArn', {
|
new cdk.CfnOutput(this, 'OAuthStartUrl', {
|
||||||
value: googleCredentialsSecret.secretArn,
|
value: oauthStartUrl,
|
||||||
description: 'Google OAuth user credentials secret ARN',
|
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', {
|
new cdk.CfnOutput(this, 'WebhookUrl', {
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ def handler(event, context):
|
|||||||
'user_profile': {
|
'user_profile': {
|
||||||
'display_name': user_profile.get('display_name', actor_id),
|
'display_name': user_profile.get('display_name', actor_id),
|
||||||
'telegram_username': user_profile.get('telegram_username', ''),
|
'telegram_username': user_profile.get('telegram_username', ''),
|
||||||
|
'google_email': user_profile.get('google_email', ''),
|
||||||
'allowed': user_profile.get('allowed', True),
|
'allowed': user_profile.get('allowed', True),
|
||||||
},
|
},
|
||||||
'channel_adapter': {
|
'channel_adapter': {
|
||||||
|
|||||||
212
src/lambdas/oauth-handler/handler.py
Normal file
212
src/lambdas/oauth-handler/handler.py
Normal 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>'
|
||||||
|
)
|
||||||
1
src/lambdas/oauth-handler/requirements.txt
Normal file
1
src/lambdas/oauth-handler/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
boto3>=1.34.0
|
||||||
@@ -9,11 +9,13 @@ RUN pip install workspace-mcp==1.20.3 boto3 --quiet
|
|||||||
# Copy bootstrap and helper scripts
|
# Copy bootstrap and helper scripts
|
||||||
COPY bootstrap /var/task/bootstrap
|
COPY bootstrap /var/task/bootstrap
|
||||||
COPY fetch_credentials.py /var/task/fetch_credentials.py
|
COPY fetch_credentials.py /var/task/fetch_credentials.py
|
||||||
|
COPY proxy.py /var/task/proxy.py
|
||||||
RUN chmod +x /var/task/bootstrap
|
RUN chmod +x /var/task/bootstrap
|
||||||
|
|
||||||
# Lambda Web Adapter config
|
# Lambda Web Adapter config — proxy listens on 8080, workspace-mcp on 8081
|
||||||
ENV AWS_LAMBDA_EXEC_WRAPPER=/opt/bootstrap
|
ENV AWS_LAMBDA_EXEC_WRAPPER=/opt/bootstrap
|
||||||
ENV PORT=8080
|
ENV PORT=8080
|
||||||
|
ENV PROXY_PORT=8080
|
||||||
ENV READINESS_CHECK_PATH=/health
|
ENV READINESS_CHECK_PATH=/health
|
||||||
|
|
||||||
CMD ["/var/task/bootstrap"]
|
CMD ["/var/task/bootstrap"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Lambda Web Adapter bootstrap for workspace-mcp
|
# Lambda Web Adapter bootstrap for workspace-mcp (multi-tenant)
|
||||||
# Dependencies are in /opt/python (Lambda layer)
|
# Proxy on port 8080 (Lambda Web Adapter entry), workspace-mcp on port 8081
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -14,14 +14,27 @@ export HOME=/tmp
|
|||||||
export WORKSPACE_MCP_LOG_DIR=/tmp
|
export WORKSPACE_MCP_LOG_DIR=/tmp
|
||||||
export GOOGLE_WORKSPACE_MCP_CREDENTIALS_DIR=/tmp/workspace_mcp_credentials
|
export GOOGLE_WORKSPACE_MCP_CREDENTIALS_DIR=/tmp/workspace_mcp_credentials
|
||||||
|
|
||||||
echo "[workspace-mcp] Fetching Google credentials..." >&2
|
echo "[workspace-mcp] Fetching default Google credentials..." >&2
|
||||||
$PYTHON /var/task/fetch_credentials.py
|
$PYTHON /var/task/fetch_credentials.py || true # non-fatal: per-user creds loaded by proxy
|
||||||
|
|
||||||
if [ -f /tmp/workspace-mcp-env ]; then
|
if [ -f /tmp/workspace-mcp-env ]; then
|
||||||
source /tmp/workspace-mcp-env
|
source /tmp/workspace-mcp-env
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[workspace-mcp] Starting on port $PORT..." >&2
|
echo "[workspace-mcp] Starting workspace-mcp on port 8081..." >&2
|
||||||
exec $PYTHON /opt/python/bin/workspace-mcp \
|
PORT=8081 $PYTHON /opt/python/bin/workspace-mcp \
|
||||||
--transport streamable-http \
|
--transport streamable-http \
|
||||||
--tool-tier extended
|
--tool-tier extended &
|
||||||
|
WMCP_PID=$!
|
||||||
|
|
||||||
|
# Wait for workspace-mcp to be ready
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -sf http://127.0.0.1:8081/health > /dev/null 2>&1; then
|
||||||
|
echo "[workspace-mcp] Ready on port 8081" >&2
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[workspace-mcp] Starting credential proxy on port $PORT..." >&2
|
||||||
|
exec $PYTHON /var/task/proxy.py
|
||||||
|
|||||||
143
src/lambdas/workspace-mcp/proxy.py
Normal file
143
src/lambdas/workspace-mcp/proxy.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""
|
||||||
|
Multi-tenant credential proxy for workspace-mcp.
|
||||||
|
|
||||||
|
Sits on port 8080 (Lambda Web Adapter entry point).
|
||||||
|
Reads X-Actor-Id header, fetches per-user Google credentials from Secrets Manager,
|
||||||
|
writes them to /tmp/workspace_mcp_credentials/{email}.json, sets USER_GOOGLE_EMAIL,
|
||||||
|
then proxies the request to workspace-mcp on port 8081.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
_sm = None
|
||||||
|
_sm_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Cache: actor_id -> (email, creds_json, fetched_at)
|
||||||
|
_creds_cache: dict = {}
|
||||||
|
_cache_ttl = 300 # 5 minutes
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sm():
|
||||||
|
global _sm
|
||||||
|
if _sm is None:
|
||||||
|
with _sm_lock:
|
||||||
|
if _sm is None:
|
||||||
|
_sm = boto3.client('secretsmanager', region_name=os.environ.get('AWS_REGION', 'us-east-1'))
|
||||||
|
return _sm
|
||||||
|
|
||||||
|
|
||||||
|
def _actor_id_to_secret_name(actor_id: str) -> str:
|
||||||
|
"""Convert actor_id to a valid Secrets Manager secret name."""
|
||||||
|
# telegram:123456789 -> agent-claw/google-credentials/telegram-123456789
|
||||||
|
safe = actor_id.replace(':', '-').replace('/', '-')
|
||||||
|
return f'agent-claw/google-credentials/{safe}'
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_credentials(actor_id: str) -> tuple[str, dict] | None:
|
||||||
|
"""Fetch Google credentials for actor_id from Secrets Manager. Returns (email, creds_dict) or None."""
|
||||||
|
now = time.time()
|
||||||
|
cached = _creds_cache.get(actor_id)
|
||||||
|
if cached and now - cached[2] < _cache_ttl:
|
||||||
|
return cached[0], cached[1]
|
||||||
|
|
||||||
|
secret_name = _actor_id_to_secret_name(actor_id)
|
||||||
|
try:
|
||||||
|
secret = _get_sm().get_secret_value(SecretId=secret_name)['SecretString']
|
||||||
|
creds = json.loads(secret)
|
||||||
|
email = creds.get('client_email') or creds.get('email') or creds.get('user_email', '')
|
||||||
|
_creds_cache[actor_id] = (email, creds, now)
|
||||||
|
print(f'[proxy] Loaded credentials for actor={actor_id} email={email}', flush=True)
|
||||||
|
return email, creds
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[proxy] No credentials for actor={actor_id}: {e}', flush=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_credentials_file(email: str, creds: dict) -> str:
|
||||||
|
"""Write credentials to /tmp/workspace_mcp_credentials/{email}.json. Returns path."""
|
||||||
|
creds_dir = '/tmp/workspace_mcp_credentials'
|
||||||
|
os.makedirs(creds_dir, exist_ok=True)
|
||||||
|
path = f'{creds_dir}/{email}.json'
|
||||||
|
with open(path, 'w') as f:
|
||||||
|
json.dump(creds, f)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyHandler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
pass # suppress default access log
|
||||||
|
|
||||||
|
def _proxy(self):
|
||||||
|
actor_id = self.headers.get('x-actor-id', '')
|
||||||
|
|
||||||
|
if actor_id:
|
||||||
|
result = _fetch_credentials(actor_id)
|
||||||
|
if result:
|
||||||
|
email, creds = result
|
||||||
|
_write_credentials_file(email, creds)
|
||||||
|
os.environ['USER_GOOGLE_EMAIL'] = email
|
||||||
|
os.environ['GOOGLE_WORKSPACE_MCP_CREDENTIALS_DIR'] = '/tmp/workspace_mcp_credentials'
|
||||||
|
else:
|
||||||
|
# No credentials found — proceed without setting email (workspace-mcp will use default or fail)
|
||||||
|
print(f'[proxy] No Google credentials for actor={actor_id}, proceeding without', flush=True)
|
||||||
|
|
||||||
|
# Read request body
|
||||||
|
content_length = int(self.headers.get('Content-Length', 0))
|
||||||
|
body = self.rfile.read(content_length) if content_length > 0 else b''
|
||||||
|
|
||||||
|
# Build upstream request
|
||||||
|
upstream_url = f'http://127.0.0.1:8081{self.path}'
|
||||||
|
upstream_headers = {k: v for k, v in self.headers.items()
|
||||||
|
if k.lower() not in ('host', 'content-length')}
|
||||||
|
if body:
|
||||||
|
upstream_headers['Content-Length'] = str(len(body))
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
upstream_url,
|
||||||
|
data=body or None,
|
||||||
|
headers=upstream_headers,
|
||||||
|
method=self.command,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
self.send_response(resp.status)
|
||||||
|
for k, v in resp.headers.items():
|
||||||
|
if k.lower() not in ('transfer-encoding',):
|
||||||
|
self.send_header(k, v)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(resp.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
self.send_response(e.code)
|
||||||
|
for k, v in e.headers.items():
|
||||||
|
if k.lower() not in ('transfer-encoding',):
|
||||||
|
self.send_header(k, v)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(e.read())
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[proxy] Upstream error: {e}', flush=True)
|
||||||
|
self.send_response(502)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'Bad Gateway')
|
||||||
|
|
||||||
|
do_GET = _proxy
|
||||||
|
do_POST = _proxy
|
||||||
|
do_PUT = _proxy
|
||||||
|
do_DELETE = _proxy
|
||||||
|
do_OPTIONS = _proxy
|
||||||
|
do_HEAD = _proxy
|
||||||
|
do_PATCH = _proxy
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
port = int(os.environ.get('PROXY_PORT', 8080))
|
||||||
|
server = HTTPServer(('0.0.0.0', port), ProxyHandler)
|
||||||
|
print(f'[proxy] Listening on port {port}', flush=True)
|
||||||
|
server.serve_forever()
|
||||||
Reference in New Issue
Block a user