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/",
|
||||
"runtimeVersion": "PYTHON_3_14",
|
||||
"networkMode": "PUBLIC",
|
||||
"protocol": "HTTP"
|
||||
"protocol": "HTTP",
|
||||
"environmentVariables": {
|
||||
"OAUTH_START_URL": "https://sptejrymri.execute-api.us-east-1.amazonaws.com/oauth/start"
|
||||
}
|
||||
}
|
||||
],
|
||||
"memories": [
|
||||
|
||||
@@ -23,12 +23,14 @@ import boto3
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
|
||||
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):
|
||||
"""SigV4 auth for Lambda Function URL with AWS_IAM."""
|
||||
def __init__(self, region: str = 'us-east-1'):
|
||||
"""SigV4 auth for Lambda Function URL with AWS_IAM, plus X-Actor-Id header."""
|
||||
def __init__(self, region: str = 'us-east-1', actor_id: str = ''):
|
||||
self._region = region
|
||||
self._actor_id = actor_id
|
||||
|
||||
def auth_flow(self, request):
|
||||
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)
|
||||
for k, v in aws_req.headers.items():
|
||||
request.headers[k] = v
|
||||
if self._actor_id:
|
||||
request.headers['x-actor-id'] = self._actor_id
|
||||
yield request
|
||||
from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig
|
||||
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
|
||||
|
||||
|
||||
@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 ────────────────────────────────────────────────────────────
|
||||
|
||||
# Module-level actor_id for tool closures (set per-invocation)
|
||||
_current_actor_id: str = ''
|
||||
|
||||
|
||||
@app.entrypoint
|
||||
def main(payload: dict, context) -> dict:
|
||||
"""Handle an invocation from agent-runner Lambda."""
|
||||
global _current_actor_id
|
||||
|
||||
# Set up channel adapter
|
||||
adapter_config = payload.get('channel_adapter', {})
|
||||
@@ -129,6 +153,7 @@ def main(payload: dict, context) -> dict:
|
||||
MEMORY_ID = 'agentclaw_AgentClawMemory-i7Csf776AH'
|
||||
actor_id = payload.get('actor_id', adapter_config.get('target_id', 'default'))
|
||||
session_id = payload.get('session_id', f'session-{actor_id}')
|
||||
_current_actor_id = actor_id
|
||||
|
||||
memory_config = AgentCoreMemoryConfig(
|
||||
memory_id=MEMORY_ID,
|
||||
@@ -146,9 +171,14 @@ def main(payload: dict, context) -> dict:
|
||||
if user_profile:
|
||||
name = user_profile.get('display_name', '')
|
||||
username = user_profile.get('telegram_username', '')
|
||||
google_email = user_profile.get('google_email', '')
|
||||
user_context = f'Name: {name}'
|
||||
if 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)
|
||||
|
||||
# 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,
|
||||
_code_interpreter.code_interpreter, home_assistant]
|
||||
_code_interpreter.code_interpreter, home_assistant, connect_google_account]
|
||||
|
||||
def _run_agent(tools):
|
||||
agent = Agent(
|
||||
@@ -170,14 +200,19 @@ def main(payload: dict, context) -> dict:
|
||||
return agent(payload.get('prompt', ''))
|
||||
|
||||
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 = []
|
||||
try:
|
||||
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')
|
||||
google_email = user_profile.get('google_email', '')
|
||||
if google_email:
|
||||
# Only attempt workspace-mcp if user has connected Google
|
||||
try:
|
||||
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:
|
||||
result = _run_agent(base_tools + list(workspace_tools))
|
||||
|
||||
Reference in New Issue
Block a user