From 95283212feab103f444185bd87c50a5e45530ff2 Mon Sep 17 00:00:00 2001 From: bgagent Date: Fri, 15 May 2026 09:50:16 -0700 Subject: [PATCH 1/4] feat(linear): resolve API token via AgentCore Identity (Phase 2.0a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates the agent runtime's Linear personal API token resolution from AWS Secrets Manager to AWS Bedrock AgentCore Identity. This is the "validate Identity SDK" step of the v2 plan; Phase 2.0b will swap the API key for OAuth and converge Linear MCP onto AgentCore Gateway in one cutover. Per Alain's guidance: "start by using api key, if it works, switch to oauth. you will setup an outbound auth for your server using agentcore identity. that identity can be (AC identity is like a wrapper around secrets manager) api key or oauth." Lambdas (orchestrator + processor) intentionally keep using Secrets Manager via the existing `LinearApiTokenSecret` for now. The Python `bedrock_agentcore` SDK has no Node.js equivalent — Lambda migration requires `@aws-sdk/client-bedrock-agentcore` raw API calls and folds into 2.0b's bigger refactor. End-state of 2.0a: agent reads from Identity, Lambdas read from Secrets Manager, both pointing at the same underlying token value (admin populates both). `agent/src/config.py::resolve_linear_api_token`: - Drops boto3 SecretsManager fetch + `LINEAR_API_TOKEN_SECRET_ARN` env. - Reads new env `LINEAR_API_KEY_PROVIDER_NAME` (provider name in Identity vault). - Calls `IdentityClient.get_api_key()` with the workload access token auto-injected into `BedrockAgentCoreContext` by AgentCore Runtime (verified by reading the SDK's `auth.py` decorator implementation — no manual workload-identity mint needed inside the runtime). - Caches the resolved token in `LINEAR_API_TOKEN` so downstream consumers stay unchanged: `channel_mcp.py`'s `${LINEAR_API_TOKEN}` placeholder in `.mcp.json` and `linear_reactions.py`'s GraphQL Authorization header. Preserves PR #87's nice-to-have improvements: - `ImportError` graceful fallback (now for `bedrock_agentcore` instead of `boto3`) — degrade with WARN, don't crash the agent. - `AccessDeniedException` and `ResourceNotFoundException` logged at ERROR severity (persistent IAM/config bugs that should page). Other ClientErrors stay at WARN (transient throttle/network). `agent/pyproject.toml`: adds `bedrock-agentcore==1.9.1` dep. `cdk/src/stacks/agent.ts`: - On the AgentCore runtime: drops `linearIntegration.apiTokenSecret. grantRead(runtime)` and the `LINEAR_API_TOKEN_SECRET_ARN` env-var override. Adds `LINEAR_API_KEY_PROVIDER_NAME` env (hardcoded `'linear-api-key'` for now; can parametrize later via context if multi-environment naming is needed) and IAM permissions for `bedrock-agentcore:GetResourceApiKey` and `bedrock-agentcore:GetWorkloadAccessToken`. - Lambdas (orchestrator + processor) untouched — they still grant on the Linear secret and read from Secrets Manager. - Resource scope on the new IAM is `*` for now; AgentCore Identity ARN format isn't fully standardized in public docs as of 2026-05-15. Tighten in 2.0b when OAuth migration documents the canonical resource shape. `docs/guides/LINEAR_SETUP_GUIDE.md`: adds Step 4.5 documenting the one-time `agentcore add credential --type api-key --name linear-api-key` admin command users must run alongside the existing `bgagent linear setup` wizard. Notes that Lambdas keep Secrets Manager temporarily and 2.0b will retire the dual-store setup. Starlight mirror synced. `agent/tests/test_config.py::TestResolveLinearApiToken` — 10 tests covering: cached env var fast-path; missing provider name; missing region; workload token absent (outside runtime); happy path with env-var side-effect; botocore error swallowed with WARN; SDK returns None defensively; ImportError fallback; AccessDeniedException → ERROR severity; ResourceNotFoundException → ERROR severity. 542 agent / 1271 cdk / 196 cli, all green. Lint + typecheck clean. CDK synth clean. `bedrock_agentcore` SDK confirmed working in our runtime image (verified in `node_modules` post-install). The `BedrockAgentCoreContext` workload token auto-injection is documented behaviour for code running inside AgentCore Runtime — verified by reading the SDK's `@requires_api_key` decorator implementation, which uses the same context lookup we use here. Stacked on PR #87 (`feat/linear-processor-feedback`). Will conflict on `config.py` and `test_config.py` if #87 needs further rework before merge — happy to rebase. Co-Authored-By: Claude Opus 4.7 (1M context) --- agent/pyproject.toml | 1 + agent/src/config.py | 95 ++++-- agent/tests/test_config.py | 278 +++++++++++++++--- agent/uv.lock | 57 ++++ cdk/src/stacks/agent.ts | 30 +- docs/guides/LINEAR_SETUP_GUIDE.md | 24 ++ .../content/docs/using/Linear-setup-guide.md | 24 ++ 7 files changed, 431 insertions(+), 78 deletions(-) diff --git a/agent/pyproject.toml b/agent/pyproject.toml index 6805f2b5..313b4013 100644 --- a/agent/pyproject.toml +++ b/agent/pyproject.toml @@ -5,6 +5,7 @@ description = "Background coding agent — runs tasks in isolated cloud environm requires-python = ">=3.13" dependencies = [ "boto3==1.43.9", #https://pypi.org/project/boto3/ + "bedrock-agentcore==1.9.1", #https://pypi.org/project/bedrock-agentcore/ "claude-agent-sdk==0.2.82", #https://github.com/anthropics/claude-agent-sdk-python/releases/tag/v0.2.82 "requests==2.34.2", #https://pypi.org/project/requests/ "fastapi==0.136.1", #https://pypi.org/project/fastapi/ diff --git a/agent/src/config.py b/agent/src/config.py index dc52c01e..05aca154 100644 --- a/agent/src/config.py +++ b/agent/src/config.py @@ -39,54 +39,101 @@ def resolve_github_token() -> str: def resolve_linear_api_token() -> str: - """Resolve the Linear personal API token from Secrets Manager or env. + """Resolve the Linear personal API token via AgentCore Identity. - Mirrors ``resolve_github_token``: in deployed mode - ``LINEAR_API_TOKEN_SECRET_ARN`` is set and the token is fetched once - and cached in ``LINEAR_API_TOKEN``. For local development, falls back - to ``LINEAR_API_TOKEN`` directly. + In deployed mode, ``LINEAR_API_KEY_PROVIDER_NAME`` names a credential + provider in AgentCore Identity (the token vault). The agent runtime + auto-injects a workload access token into ``BedrockAgentCoreContext``; + we exchange that for the API key value and cache it in + ``LINEAR_API_TOKEN`` so downstream consumers (the Linear MCP's + ``${LINEAR_API_TOKEN}`` placeholder in ``.mcp.json`` and + ``linear_reactions.py``'s GraphQL Authorization header) keep working + unchanged. - Returns an empty string if the secret is absent or empty — the agent-side + For local development, falls back to a pre-set ``LINEAR_API_TOKEN`` + env var so the agent can run outside AgentCore Runtime. + + Returns an empty string if the credential is absent — the agent-side MCP config then renders with an unresolved ``${LINEAR_API_TOKEN}`` env placeholder, and the Linear MCP will reject the request (fail-closed). This function is only called when ``channel_source == 'linear'``. + + Phase 2.0a: replaces the prior Secrets Manager path. Phase 2.0b will + swap this function entirely for the ``@requires_access_token`` OAuth + decorator pattern; this imperative shape exists because API keys + don't need refresh and the MCP config expects a static token. """ cached = os.environ.get("LINEAR_API_TOKEN", "") if cached: return cached - secret_arn = os.environ.get("LINEAR_API_TOKEN_SECRET_ARN") - if not secret_arn: + + provider_name = os.environ.get("LINEAR_API_KEY_PROVIDER_NAME") + if not provider_name: return "" + + region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") + if not region: + log("WARN", "resolve_linear_api_token: AWS_REGION not set; cannot resolve API key") + return "" + try: - import boto3 + import asyncio + + from bedrock_agentcore.runtime import BedrockAgentCoreContext + from bedrock_agentcore.services.identity import IdentityClient from botocore.exceptions import BotoCoreError, ClientError except ImportError as e: - # boto3 missing from the container image — degrade gracefully rather - # than hard-crashing the agent. The Linear MCP will fail on first - # call with a clear auth error. - log("WARN", f"resolve_linear_api_token: boto3 unavailable ({e}); skipping") + # bedrock_agentcore SDK missing from the container image — degrade + # gracefully rather than hard-crashing the agent. The Linear MCP + # will fail on first call with a clear auth error. + log("WARN", f"resolve_linear_api_token: bedrock_agentcore unavailable ({e}); skipping") return "" try: - region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") - client = boto3.client("secretsmanager", region_name=region) - resp = client.get_secret_value(SecretId=secret_arn) - token = resp.get("SecretString", "") or "" + workload_token = BedrockAgentCoreContext.get_workload_access_token() + if workload_token is None: + # Outside the AgentCore Runtime container (e.g. local dev). The + # SDK's `_set_up_local_auth` fallback writes `.agentcore.json` + # which doesn't fit our flow; bail out and let the caller see + # an empty token so the MCP config fails closed. + log( + "WARN", + "resolve_linear_api_token: workload access token not in context " + "(agent must run inside AgentCore Runtime, or set LINEAR_API_TOKEN " + "directly for local dev)", + ) + return "" + + client = IdentityClient(region=region) + token = ( + asyncio.run( + client.get_api_key( + provider_name=provider_name, + agent_identity_token=workload_token, + ) + ) + or "" + ) if token: os.environ["LINEAR_API_TOKEN"] = token return token except ClientError as e: - # Narrowed from a broader `except` per #63 review — broader catches - # hid genuine bugs in the Secrets Manager call shape. AccessDenied - # is logged at ERROR because it's a persistent IAM misconfig that - # should page someone, not a transient blip. + # Narrowed from a broader `except` per #63 review. AccessDenied is + # logged at ERROR because it's a persistent IAM misconfig (likely + # the runtime role missing bedrock-agentcore:GetResourceApiKey or + # GetWorkloadAccessToken) that should page someone, not a transient + # blip. ResourceNotFound (provider name unknown) is also persistent + # — same severity. Other ClientErrors are likely transient (throttle, + # network blip) and stay at WARN. code = e.response.get("Error", {}).get("Code", "") - severity = "ERROR" if code == "AccessDeniedException" else "WARN" + severity = ( + "ERROR" if code in ("AccessDeniedException", "ResourceNotFoundException") else "WARN" + ) log(severity, f"resolve_linear_api_token failed: {type(e).__name__}: {e}") return "" except BotoCoreError as e: - # Never let a Secrets Manager outage crash the agent. The Linear MCP - # will simply fail on first call with a clear auth error. + # Never let an Identity outage crash the agent. The Linear MCP will + # fail on first call with a clear auth error. log("WARN", f"resolve_linear_api_token failed: {type(e).__name__}: {e}") return "" diff --git a/agent/tests/test_config.py b/agent/tests/test_config.py index 4421945e..d60ddb5c 100644 --- a/agent/tests/test_config.py +++ b/agent/tests/test_config.py @@ -91,86 +91,268 @@ def test_auto_generated_task_id(self): class TestResolveLinearApiToken: - """Coverage for the secrets-manager + boto3 fallback paths.""" + """Phase 2.0a: token resolves via AgentCore Identity, not Secrets Manager. + + Pins the contract that `LINEAR_API_TOKEN` env var is the public surface + (consumed by `channel_mcp.py`'s `${LINEAR_API_TOKEN}` MCP placeholder + and `linear_reactions.py`'s GraphQL Authorization header). Only the + *source* of the value changed: previously boto3 secretsmanager, now + bedrock_agentcore Identity. + """ + + def test_returns_cached_value_without_calling_identity(self, monkeypatch): + """Fast-path: if LINEAR_API_TOKEN is already set, no SDK call fires.""" + monkeypatch.setenv("LINEAR_API_TOKEN", "lin_api_cached") + with patch("bedrock_agentcore.services.identity.IdentityClient") as mock_client: + assert resolve_linear_api_token() == "lin_api_cached" + mock_client.assert_not_called() + + def test_returns_empty_when_provider_name_missing(self, monkeypatch): + """Without LINEAR_API_KEY_PROVIDER_NAME, no source — return empty cleanly.""" + monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) + monkeypatch.delenv("LINEAR_API_KEY_PROVIDER_NAME", raising=False) + with patch("bedrock_agentcore.services.identity.IdentityClient") as mock_client: + assert resolve_linear_api_token() == "" + mock_client.assert_not_called() - def test_returns_cached_env_var_without_calling_boto(self, monkeypatch): - monkeypatch.setenv("LINEAR_API_TOKEN", "lin_cached") - monkeypatch.setenv("LINEAR_API_TOKEN_SECRET_ARN", "arn:aws:sm:::secret/linear") - # boto3 must not be touched if the env var is already set. - with patch("config.log") as mock_log: - assert resolve_linear_api_token() == "lin_cached" - mock_log.assert_not_called() + def test_returns_empty_when_region_missing(self, monkeypatch): + """No region → can't construct IdentityClient → empty + WARN, no SDK call.""" + monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) + monkeypatch.setenv("LINEAR_API_KEY_PROVIDER_NAME", "linear-api-key") + monkeypatch.delenv("AWS_REGION", raising=False) + monkeypatch.delenv("AWS_DEFAULT_REGION", raising=False) + with patch("bedrock_agentcore.services.identity.IdentityClient") as mock_client: + assert resolve_linear_api_token() == "" + mock_client.assert_not_called() + + def test_returns_empty_when_workload_token_not_in_context(self, monkeypatch): + """Outside AgentCore Runtime, BedrockAgentCoreContext returns None. + Don't try the local-auth fallback (writes .agentcore.json which + doesn't fit our flow) — just return empty so MCP fails closed.""" + monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) + monkeypatch.setenv("LINEAR_API_KEY_PROVIDER_NAME", "linear-api-key") + monkeypatch.setenv("AWS_REGION", "us-east-1") + with patch( + "bedrock_agentcore.runtime.BedrockAgentCoreContext.get_workload_access_token", + return_value=None, + ): + assert resolve_linear_api_token() == "" + + def test_resolves_from_identity_and_caches_in_env(self, monkeypatch): + """Happy path: workload token in context → IdentityClient.get_api_key + returns the API key → set LINEAR_API_TOKEN env var → return token.""" + monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) + monkeypatch.setenv("LINEAR_API_KEY_PROVIDER_NAME", "linear-api-key") + monkeypatch.setenv("AWS_REGION", "us-east-1") + + mock_instance = MagicMock() + + async def _get_key(provider_name, agent_identity_token): + return "lin_api_resolved" + + mock_instance.get_api_key = _get_key + + with ( + patch( + "bedrock_agentcore.runtime.BedrockAgentCoreContext.get_workload_access_token", + return_value="workload-token-abc", + ), + patch( + "bedrock_agentcore.services.identity.IdentityClient", + return_value=mock_instance, + ) as mock_client_class, + ): + result = resolve_linear_api_token() + + assert result == "lin_api_resolved" + # Construction passed the resolved region. + mock_client_class.assert_called_once_with(region="us-east-1") + # Side effect: env var populated for downstream consumers. + import os + + assert os.environ.get("LINEAR_API_TOKEN") == "lin_api_resolved" + + def test_swallows_botocore_errors_and_logs_warn(self, monkeypatch): + """Identity outages must NEVER crash the agent. Return empty + WARN; + the Linear MCP will then fail on first call with a clear auth error.""" + from botocore.exceptions import ClientError + + monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) + monkeypatch.setenv("LINEAR_API_KEY_PROVIDER_NAME", "linear-api-key") + monkeypatch.setenv("AWS_REGION", "us-east-1") + + mock_instance = MagicMock() + + async def _raise(provider_name, agent_identity_token): + raise ClientError( + {"Error": {"Code": "AccessDenied", "Message": "denied"}}, + "GetResourceApiKey", + ) + + mock_instance.get_api_key = _raise + + with ( + patch( + "bedrock_agentcore.runtime.BedrockAgentCoreContext.get_workload_access_token", + return_value="workload-token-abc", + ), + patch( + "bedrock_agentcore.services.identity.IdentityClient", + return_value=mock_instance, + ), + ): + # Must not raise. + assert resolve_linear_api_token() == "" - def test_returns_empty_when_no_secret_arn(self, monkeypatch): + def test_returns_empty_when_get_api_key_returns_none(self, monkeypatch): + """Defensive: if the SDK returns None (provider exists but no value), + return empty rather than coercing to the string 'None'.""" monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) - monkeypatch.delenv("LINEAR_API_TOKEN_SECRET_ARN", raising=False) - assert resolve_linear_api_token() == "" + monkeypatch.setenv("LINEAR_API_KEY_PROVIDER_NAME", "linear-api-key") + monkeypatch.setenv("AWS_REGION", "us-east-1") + + mock_instance = MagicMock() + + async def _get_none(provider_name, agent_identity_token): + return None + + mock_instance.get_api_key = _get_none + + with ( + patch( + "bedrock_agentcore.runtime.BedrockAgentCoreContext.get_workload_access_token", + return_value="workload-token-abc", + ), + patch( + "bedrock_agentcore.services.identity.IdentityClient", + return_value=mock_instance, + ), + ): + assert resolve_linear_api_token() == "" def test_import_error_degrades_gracefully(self, monkeypatch): - """If boto3 is missing from the container image, log WARN and return '' - rather than crashing the agent.""" + """If bedrock_agentcore SDK is missing from the container image, log + WARN and return '' rather than crashing the agent. Adapted from PR #87 + nice-to-have improvement (the boto3 ImportError version) for the + AgentCore SDK migration.""" monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) - monkeypatch.setenv("LINEAR_API_TOKEN_SECRET_ARN", "arn:aws:sm:::secret/linear") - # Force `import boto3` (executed inside resolve_linear_api_token) to - # raise ImportError by removing it from sys.modules and shadowing it. - monkeypatch.setitem(sys.modules, "boto3", None) + monkeypatch.setenv("LINEAR_API_KEY_PROVIDER_NAME", "linear-api-key") + monkeypatch.setenv("AWS_REGION", "us-east-1") + # Force `import bedrock_agentcore.services.identity` to raise ImportError. + monkeypatch.setitem(sys.modules, "bedrock_agentcore.services.identity", None) with patch("config.log") as mock_log: assert resolve_linear_api_token() == "" # WARN logged, no exception escaped. - assert mock_log.call_count == 1 - assert mock_log.call_args[0][0] == "WARN" - assert "boto3 unavailable" in mock_log.call_args[0][1] + assert mock_log.call_count >= 1 + assert any(call.args[0] == "WARN" for call in mock_log.call_args_list) + assert any( + "bedrock_agentcore unavailable" in call.args[1] for call in mock_log.call_args_list + ) def test_access_denied_logged_at_error(self, monkeypatch): """Persistent IAM misconfig should page someone — escalate from WARN - to ERROR so alerts fire.""" + to ERROR so alerts fire. Adapted from PR #87 nice-to-have + improvement; the AgentCore equivalent is a missing + `bedrock-agentcore:GetResourceApiKey` IAM permission.""" monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) - monkeypatch.setenv("LINEAR_API_TOKEN_SECRET_ARN", "arn:aws:sm:::secret/linear") + monkeypatch.setenv("LINEAR_API_KEY_PROVIDER_NAME", "linear-api-key") + monkeypatch.setenv("AWS_REGION", "us-east-1") from botocore.exceptions import ClientError - err = ClientError( - {"Error": {"Code": "AccessDeniedException", "Message": "no access"}}, - "GetSecretValue", - ) - fake_client = MagicMock() - fake_client.get_secret_value.side_effect = err - with patch("boto3.client", return_value=fake_client), patch("config.log") as mock_log: + mock_instance = MagicMock() + + async def _raise_access_denied(provider_name, agent_identity_token): + raise ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "no access"}}, + "GetResourceApiKey", + ) + + mock_instance.get_api_key = _raise_access_denied + + with ( + patch( + "bedrock_agentcore.runtime.BedrockAgentCoreContext.get_workload_access_token", + return_value="workload-token-abc", + ), + patch( + "bedrock_agentcore.services.identity.IdentityClient", + return_value=mock_instance, + ), + patch("config.log") as mock_log, + ): assert resolve_linear_api_token() == "" assert mock_log.call_count == 1 assert mock_log.call_args[0][0] == "ERROR" - def test_other_client_error_logged_at_warn(self, monkeypatch): + def test_resource_not_found_logged_at_error(self, monkeypatch): + """Provider name typo / missing credential is also persistent — page + someone rather than warn forever. Both AccessDeniedException and + ResourceNotFoundException take the ERROR path; everything else stays + at WARN (transient throttle, network blip).""" monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) - monkeypatch.setenv("LINEAR_API_TOKEN_SECRET_ARN", "arn:aws:sm:::secret/linear") + monkeypatch.setenv("LINEAR_API_KEY_PROVIDER_NAME", "linear-api-key") + monkeypatch.setenv("AWS_REGION", "us-east-1") from botocore.exceptions import ClientError - err = ClientError( - {"Error": {"Code": "ResourceNotFoundException", "Message": "missing"}}, - "GetSecretValue", - ) - fake_client = MagicMock() - fake_client.get_secret_value.side_effect = err - with patch("boto3.client", return_value=fake_client), patch("config.log") as mock_log: + mock_instance = MagicMock() + + async def _raise_not_found(provider_name, agent_identity_token): + raise ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": "missing provider"}}, + "GetResourceApiKey", + ) + + mock_instance.get_api_key = _raise_not_found + + with ( + patch( + "bedrock_agentcore.runtime.BedrockAgentCoreContext.get_workload_access_token", + return_value="workload-token-abc", + ), + patch( + "bedrock_agentcore.services.identity.IdentityClient", + return_value=mock_instance, + ), + patch("config.log") as mock_log, + ): assert resolve_linear_api_token() == "" assert mock_log.call_count == 1 - assert mock_log.call_args[0][0] == "WARN" + assert mock_log.call_args[0][0] == "ERROR" def test_botocore_error_logged_at_warn(self, monkeypatch): - """The handler is split into ClientError + BotoCoreError branches. - BotoCoreError covers transient connectivity / endpoint problems — - log WARN and degrade gracefully rather than crashing the agent.""" + """The handler splits the except into ClientError + BotoCoreError + branches. BotoCoreError covers transient connectivity / endpoint + problems — log WARN and degrade gracefully rather than crashing + the agent. (Adapted from PR #87's Secrets Manager equivalent for + the AgentCore Identity SDK call.)""" monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) - monkeypatch.setenv("LINEAR_API_TOKEN_SECRET_ARN", "arn:aws:sm:::secret/linear") + monkeypatch.setenv("LINEAR_API_KEY_PROVIDER_NAME", "linear-api-key") + monkeypatch.setenv("AWS_REGION", "us-east-1") from botocore.exceptions import EndpointConnectionError - fake_client = MagicMock() - fake_client.get_secret_value.side_effect = EndpointConnectionError( - endpoint_url="https://secretsmanager.us-east-1.amazonaws.com", - ) - with patch("boto3.client", return_value=fake_client), patch("config.log") as mock_log: + mock_instance = MagicMock() + + async def _raise_endpoint(provider_name, agent_identity_token): + raise EndpointConnectionError( + endpoint_url="https://bedrock-agentcore.us-east-1.amazonaws.com", + ) + + mock_instance.get_api_key = _raise_endpoint + + with ( + patch( + "bedrock_agentcore.runtime.BedrockAgentCoreContext.get_workload_access_token", + return_value="workload-token-abc", + ), + patch( + "bedrock_agentcore.services.identity.IdentityClient", + return_value=mock_instance, + ), + patch("config.log") as mock_log, + ): assert resolve_linear_api_token() == "" assert mock_log.call_count == 1 assert mock_log.call_args[0][0] == "WARN" diff --git a/agent/uv.lock b/agent/uv.lock index 197d606d..f367a7b4 100644 --- a/agent/uv.lock +++ b/agent/uv.lock @@ -133,6 +133,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "aws-opentelemetry-distro" }, + { name = "bedrock-agentcore" }, { name = "boto3" }, { name = "cedarpy" }, { name = "claude-agent-sdk" }, @@ -153,6 +154,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aws-opentelemetry-distro", specifier = "==0.17.0" }, + { name = "bedrock-agentcore", specifier = "==1.9.1" }, { name = "boto3", specifier = "==1.43.9" }, { name = "cedarpy", specifier = "==4.8.3" }, { name = "claude-agent-sdk", specifier = "==0.2.82" }, @@ -170,6 +172,25 @@ dev = [ { name = "ty" }, ] +[[package]] +name = "bedrock-agentcore" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boto3" }, + { name = "botocore" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "uvicorn" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/ba/91b6ec49558755cccc5bfa5a64916995baed5490768bee33581b370a1e4e/bedrock_agentcore-1.9.1.tar.gz", hash = "sha256:f0e69b41c32c12e395d698299c96981d34035dafa90e0e79fcbd743574315c6a", size = 692593, upload-time = "2026-05-12T21:50:47.639Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/05/a5fbaa2320c34f8df196c105ca1938848845216cacc36850c73d116f28a9/bedrock_agentcore-1.9.1-py3-none-any.whl", hash = "sha256:f323c3d943dfe1defd52febd1409f8c4d04c0fc37848dd100ede692c2a6addd2", size = 262193, upload-time = "2026-05-12T21:50:45.506Z" }, +] + [[package]] name = "boto3" version = "1.43.9" @@ -2053,6 +2074,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" }, ] +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + [[package]] name = "wrapt" version = "1.17.3" diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index ba3001c0..a43db78f 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -595,14 +595,32 @@ export class AgentStack extends Stack { guardrailVersion: inputGuardrail.guardrailVersion, }); - // Pipe the Linear API token secret into the AgentCore runtime so the - // agent's `resolve_linear_api_token()` can populate `LINEAR_API_TOKEN` - // for the Linear MCP's `${LINEAR_API_TOKEN}` placeholder. - linearIntegration.apiTokenSecret.grantRead(runtime); + // Phase 2.0a: agent runtime resolves the Linear API token via AgentCore + // Identity, not Secrets Manager. The credential lives in an Identity + // api-key provider; the runtime container's resolve_linear_api_token() + // exchanges its auto-injected workload access token for the API key + // value. Phase 2.0b will swap this for an OAuth provider + Gateway. + // + // Lambdas (orchestrator + processor) are intentionally NOT migrated + // here — the bedrock_agentcore Python SDK has no Node.js equivalent; + // they keep using Secrets Manager via `linearIntegration.apiTokenSecret` + // until 2.0b's full cutover. + const linearApiKeyProviderName = 'linear-api-key'; cfnRuntime.addPropertyOverride( - 'EnvironmentVariables.LINEAR_API_TOKEN_SECRET_ARN', - linearIntegration.apiTokenSecret.secretArn, + 'EnvironmentVariables.LINEAR_API_KEY_PROVIDER_NAME', + linearApiKeyProviderName, ); + runtime.role.addToPrincipalPolicy(new iam.PolicyStatement({ + actions: [ + 'bedrock-agentcore:GetResourceApiKey', + 'bedrock-agentcore:GetWorkloadAccessToken', + ], + // AgentCore Identity ARN format isn't fully standardized in public + // docs as of 2026-05-14; scope to all bedrock-agentcore resources in + // this account/region. Tighten to specific provider/workload ARNs in + // 2.0b once OAuth migration documents the canonical resource shape. + resources: ['*'], + })); // Pipe the Linear API token secret into the orchestrator Lambda so the // concurrency-cap rejection path can post a Linear comment + ❌ instead diff --git a/docs/guides/LINEAR_SETUP_GUIDE.md b/docs/guides/LINEAR_SETUP_GUIDE.md index 64d221bc..3a1429d4 100644 --- a/docs/guides/LINEAR_SETUP_GUIDE.md +++ b/docs/guides/LINEAR_SETUP_GUIDE.md @@ -57,6 +57,30 @@ Both are stored in Secrets Manager (`LinearWebhookSecret` and `LinearApiTokenSec As a final step, `setup` calls the Linear API with the token you just stored, looks up the token owner, and auto-links that Linear identity to the Cognito user currently logged in to the CLI. This skips the code-exchange ceremony for the common case where one person installs ABCA for their own workspace. If the auto-link fails (token invalid, not logged in, etc.) setup prints a warning and continues. +### Step 4.5: Register the API token with AgentCore Identity + +Phase 2.0a (May 2026) added a second consumer for the Linear token: the **agent runtime container** resolves the token through AWS Bedrock AgentCore Identity rather than Secrets Manager, so the agent never needs IAM read permission on `LinearApiTokenSecret` at the runtime layer. + +> **Why two stores?** Lambdas (the webhook processor and orchestrator) keep using Secrets Manager because the AgentCore Identity SDK doesn't ship a Node.js client yet. The agent container (Python) uses Identity. Phase 2.0b will migrate Lambdas to OAuth via Identity, retire Secrets Manager for Linear, and converge on a single store. + +After completing Step 4, register the **same** API token with AgentCore Identity (one-time, admin): + +```bash +agentcore add credential --type api-key --name linear-api-key +# (paste the same lin_api_… token when prompted) +``` + +The CDK stack hardcodes the provider name `linear-api-key`. If you use a different name, override `LINEAR_API_KEY_PROVIDER_NAME` on the AgentCore runtime in `cdk/src/stacks/agent.ts`. + +To verify the credential was stored: + +```bash +agentcore list credentials +# linear-api-key (api-key) created: … +``` + +If you skip this step the agent's `resolve_linear_api_token()` returns an empty string, the Linear MCP fails with an auth error on the first call, and you'll see `WARN linear_reactions: HTTP 401 from Linear` in CloudWatch. + **If auto-link fails persistently** (rare — usually transient Linear API hiccups, just re-run `bgagent linear setup`), an admin can insert the mapping directly into the `LinearUserMappingTable` DynamoDB table: ```bash diff --git a/docs/src/content/docs/using/Linear-setup-guide.md b/docs/src/content/docs/using/Linear-setup-guide.md index eedc9c8e..1593816d 100644 --- a/docs/src/content/docs/using/Linear-setup-guide.md +++ b/docs/src/content/docs/using/Linear-setup-guide.md @@ -61,6 +61,30 @@ Both are stored in Secrets Manager (`LinearWebhookSecret` and `LinearApiTokenSec As a final step, `setup` calls the Linear API with the token you just stored, looks up the token owner, and auto-links that Linear identity to the Cognito user currently logged in to the CLI. This skips the code-exchange ceremony for the common case where one person installs ABCA for their own workspace. If the auto-link fails (token invalid, not logged in, etc.) setup prints a warning and continues. +### Step 4.5: Register the API token with AgentCore Identity + +Phase 2.0a (May 2026) added a second consumer for the Linear token: the **agent runtime container** resolves the token through AWS Bedrock AgentCore Identity rather than Secrets Manager, so the agent never needs IAM read permission on `LinearApiTokenSecret` at the runtime layer. + +> **Why two stores?** Lambdas (the webhook processor and orchestrator) keep using Secrets Manager because the AgentCore Identity SDK doesn't ship a Node.js client yet. The agent container (Python) uses Identity. Phase 2.0b will migrate Lambdas to OAuth via Identity, retire Secrets Manager for Linear, and converge on a single store. + +After completing Step 4, register the **same** API token with AgentCore Identity (one-time, admin): + +```bash +agentcore add credential --type api-key --name linear-api-key +# (paste the same lin_api_… token when prompted) +``` + +The CDK stack hardcodes the provider name `linear-api-key`. If you use a different name, override `LINEAR_API_KEY_PROVIDER_NAME` on the AgentCore runtime in `cdk/src/stacks/agent.ts`. + +To verify the credential was stored: + +```bash +agentcore list credentials +# linear-api-key (api-key) created: … +``` + +If you skip this step the agent's `resolve_linear_api_token()` returns an empty string, the Linear MCP fails with an auth error on the first call, and you'll see `WARN linear_reactions: HTTP 401 from Linear` in CloudWatch. + **If auto-link fails persistently** (rare — usually transient Linear API hiccups, just re-run `bgagent linear setup`), an admin can insert the mapping directly into the `LinearUserMappingTable` DynamoDB table: ```bash From e7125daba842dae090cc06a0e4a56ade7912b076 Mon Sep 17 00:00:00 2001 From: bgagent Date: Mon, 18 May 2026 12:01:42 -0700 Subject: [PATCH 2/4] docs(linear): use aws CLI for credential provider, not the agentcore command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setup guide referenced `agentcore add credential` which doesn't actually work end-to-end: - The Python `bedrock-agentcore-starter-toolkit` CLI (`agentcore`) only exposes agent-lifecycle commands; there is no `credential-provider` subcommand. Confirmed by reading the toolkit's CLI reference and by user trying `agentcore configure credential-provider --type api-key --name ...` and receiving `No such command 'credential-provider'`. - The new npm `@aws/agentcore` CLI does have `agentcore add credential` but uses a declarative project model — the credential lands in `agentcore.json` + `.env.local`, not the actual AgentCore Identity vault, until `agentcore deploy` runs against a project structured for that CLI. ABCA isn't structured that way. Switch the docs to the plain AWS CLI which works directly against the AgentCore Identity API: aws bedrock-agentcore-control create-api-key-credential-provider \ --name linear-api-key \ --api-key "" \ --region us-east-1 Plus the matching `list-api-key-credential-providers` for verification. Add a "Tooling note" at the bottom of the section explaining why the plain AWS CLI is the right path here vs. the two `agentcore` CLIs. Starlight mirror synced. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/guides/LINEAR_SETUP_GUIDE.md | 14 +++++++++----- docs/src/content/docs/using/Linear-setup-guide.md | 14 +++++++++----- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/docs/guides/LINEAR_SETUP_GUIDE.md b/docs/guides/LINEAR_SETUP_GUIDE.md index 3a1429d4..52926b28 100644 --- a/docs/guides/LINEAR_SETUP_GUIDE.md +++ b/docs/guides/LINEAR_SETUP_GUIDE.md @@ -63,11 +63,13 @@ Phase 2.0a (May 2026) added a second consumer for the Linear token: the **agent > **Why two stores?** Lambdas (the webhook processor and orchestrator) keep using Secrets Manager because the AgentCore Identity SDK doesn't ship a Node.js client yet. The agent container (Python) uses Identity. Phase 2.0b will migrate Lambdas to OAuth via Identity, retire Secrets Manager for Linear, and converge on a single store. -After completing Step 4, register the **same** API token with AgentCore Identity (one-time, admin): +After completing Step 4, register the **same** API token with AgentCore Identity (one-time, admin). Use the AWS CLI directly — no extra tooling required: ```bash -agentcore add credential --type api-key --name linear-api-key -# (paste the same lin_api_… token when prompted) +aws bedrock-agentcore-control create-api-key-credential-provider \ + --name linear-api-key \ + --api-key "" \ + --region us-east-1 ``` The CDK stack hardcodes the provider name `linear-api-key`. If you use a different name, override `LINEAR_API_KEY_PROVIDER_NAME` on the AgentCore runtime in `cdk/src/stacks/agent.ts`. @@ -75,12 +77,14 @@ The CDK stack hardcodes the provider name `linear-api-key`. If you use a differe To verify the credential was stored: ```bash -agentcore list credentials -# linear-api-key (api-key) created: … +aws bedrock-agentcore-control list-api-key-credential-providers --region us-east-1 +# Look for "name": "linear-api-key" in the output ``` If you skip this step the agent's `resolve_linear_api_token()` returns an empty string, the Linear MCP fails with an auth error on the first call, and you'll see `WARN linear_reactions: HTTP 401 from Linear` in CloudWatch. +> **Tooling note.** If you have the `bedrock-agentcore-starter-toolkit` Python CLI (`agentcore` command) installed for other reasons, it does **not** expose a credential-provider subcommand — that toolkit is for agent lifecycle (`agentcore configure agent`, `agentcore deploy`), not Identity vault management. The new npm `@aws/agentcore` CLI uses a declarative `agentcore.json` project model that doesn't fit ABCA's setup either. The `aws bedrock-agentcore-control` AWS CLI commands above are the cleanest path. + **If auto-link fails persistently** (rare — usually transient Linear API hiccups, just re-run `bgagent linear setup`), an admin can insert the mapping directly into the `LinearUserMappingTable` DynamoDB table: ```bash diff --git a/docs/src/content/docs/using/Linear-setup-guide.md b/docs/src/content/docs/using/Linear-setup-guide.md index 1593816d..2ad5f43e 100644 --- a/docs/src/content/docs/using/Linear-setup-guide.md +++ b/docs/src/content/docs/using/Linear-setup-guide.md @@ -67,11 +67,13 @@ Phase 2.0a (May 2026) added a second consumer for the Linear token: the **agent > **Why two stores?** Lambdas (the webhook processor and orchestrator) keep using Secrets Manager because the AgentCore Identity SDK doesn't ship a Node.js client yet. The agent container (Python) uses Identity. Phase 2.0b will migrate Lambdas to OAuth via Identity, retire Secrets Manager for Linear, and converge on a single store. -After completing Step 4, register the **same** API token with AgentCore Identity (one-time, admin): +After completing Step 4, register the **same** API token with AgentCore Identity (one-time, admin). Use the AWS CLI directly — no extra tooling required: ```bash -agentcore add credential --type api-key --name linear-api-key -# (paste the same lin_api_… token when prompted) +aws bedrock-agentcore-control create-api-key-credential-provider \ + --name linear-api-key \ + --api-key "" \ + --region us-east-1 ``` The CDK stack hardcodes the provider name `linear-api-key`. If you use a different name, override `LINEAR_API_KEY_PROVIDER_NAME` on the AgentCore runtime in `cdk/src/stacks/agent.ts`. @@ -79,12 +81,14 @@ The CDK stack hardcodes the provider name `linear-api-key`. If you use a differe To verify the credential was stored: ```bash -agentcore list credentials -# linear-api-key (api-key) created: … +aws bedrock-agentcore-control list-api-key-credential-providers --region us-east-1 +# Look for "name": "linear-api-key" in the output ``` If you skip this step the agent's `resolve_linear_api_token()` returns an empty string, the Linear MCP fails with an auth error on the first call, and you'll see `WARN linear_reactions: HTTP 401 from Linear` in CloudWatch. +> **Tooling note.** If you have the `bedrock-agentcore-starter-toolkit` Python CLI (`agentcore` command) installed for other reasons, it does **not** expose a credential-provider subcommand — that toolkit is for agent lifecycle (`agentcore configure agent`, `agentcore deploy`), not Identity vault management. The new npm `@aws/agentcore` CLI uses a declarative `agentcore.json` project model that doesn't fit ABCA's setup either. The `aws bedrock-agentcore-control` AWS CLI commands above are the cleanest path. + **If auto-link fails persistently** (rare — usually transient Linear API hiccups, just re-run `bgagent linear setup`), an admin can insert the mapping directly into the `LinearUserMappingTable` DynamoDB table: ```bash From 37a574b817a9ebf9d81ec0e0cf8b0f2dc0612725 Mon Sep 17 00:00:00 2001 From: bgagent Date: Mon, 18 May 2026 12:35:58 -0700 Subject: [PATCH 3/4] fix(linear): pass runtimeUserId so AgentCore injects WorkloadAccessToken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smoke on backgroundagent-dev caught a real bug in the Phase 2.0a migration: the agent's `resolve_linear_api_token()` was correctly calling `IdentityClient.get_api_key()` but failing earlier at `BedrockAgentCoreContext.get_workload_access_token()` returning None. The Linear MCP then loaded with an unresolved `${LINEAR_API_TOKEN}` placeholder and 👀 didn't post. Root cause (from reading bedrock-agentcore-sdk-python source): The `WorkloadAccessToken` request header (which the runtime container reads to populate `BedrockAgentCoreContext`) is only injected by AgentCore Identity when `InvokeAgentRuntimeCommand` is called with `runtimeUserId`. Per AWS docs at https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-oauth.html: "Agent Runtime exchanges this token for a Workload Access Token via bedrock-agentcore:GetWorkloadAccessTokenForJWT API and delivers it to your agent code via the payload header `WorkloadAccessToken`." Without `runtimeUserId`, AgentCore never derives a workload token and the header is absent. `app.py::_build_request_context` reads the header off the inbound request; the agent sees None. Fix: 1. Thread `userId` through the `ComputeStrategy.startSession` interface (compute-strategy.ts). 2. Pass `task.user_id` (the task's Cognito sub) at the call site in orchestrate-task.ts. 3. Set `runtimeUserId: input.userId` on `InvokeAgentRuntimeCommand` in agentcore-strategy.ts. Log it alongside session_id for traceability. 4. ECS strategy accepts the new parameter to satisfy the interface; doesn't use it (ECS doesn't go through AgentCore Identity). 5. Grant the orchestrator role `bedrock-agentcore:InvokeAgentRuntimeForUser` alongside `InvokeAgentRuntime` (task-orchestrator.ts). Without this, the new `runtimeUserId` parameter would 403. Tests updated: - `agentcore-strategy.test.ts`: pin that `runtimeUserId` flows from input into the SDK command; pass `userId: 'cognito-user-1'` in 4 call sites. - `ecs-strategy.test.ts`: pass `userId` (unused by ECS) on 3 call sites. - `start-session-composition.test.ts`: pass `userId: 'cognito-test'` on 3 call sites. - `task-orchestrator.test.ts`: assert the IAM action list includes `InvokeAgentRuntimeForUser` (2 assertions). 542 agent / 1273 cdk / 196 cli — all green. Lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- cdk/src/constructs/task-orchestrator.ts | 8 ++++++++ cdk/src/handlers/orchestrate-task.ts | 7 ++++++- cdk/src/handlers/shared/compute-strategy.ts | 12 ++++++++++++ .../shared/strategies/agentcore-strategy.ts | 18 +++++++++++++++++- .../handlers/shared/strategies/ecs-strategy.ts | 3 +++ cdk/test/constructs/task-orchestrator.test.ts | 2 ++ .../strategies/agentcore-strategy.test.ts | 7 +++++++ .../shared/strategies/ecs-strategy.test.ts | 3 +++ .../handlers/start-session-composition.test.ts | 6 +++--- 9 files changed, 61 insertions(+), 5 deletions(-) diff --git a/cdk/src/constructs/task-orchestrator.ts b/cdk/src/constructs/task-orchestrator.ts index ceb82733..bf40849c 100644 --- a/cdk/src/constructs/task-orchestrator.ts +++ b/cdk/src/constructs/task-orchestrator.ts @@ -245,11 +245,19 @@ export class TaskOrchestrator extends Construct { // AgentCore runtime invocation permissions // The InvokeAgentRuntime API targets a sub-resource (runtime-endpoint/DEFAULT), // so we need a wildcard after the runtime ARN. + // + // `InvokeAgentRuntimeForUser` is required when the call passes + // `runtimeUserId` (Phase 2.0a — needed for AgentCore Identity to + // inject a `WorkloadAccessToken` header into the agent container so + // `BedrockAgentCoreContext.get_workload_access_token()` returns + // non-None). Without this grant, `InvokeAgentRuntimeCommand` with + // `runtimeUserId` set fails with AccessDenied. const runtimeArns = [props.runtimeArn, ...(props.additionalRuntimeArns ?? [])]; const runtimeResources = runtimeArns.flatMap(arn => [arn, `${arn}/*`]); this.fn.addToRolePolicy(new iam.PolicyStatement({ actions: [ 'bedrock-agentcore:InvokeAgentRuntime', + 'bedrock-agentcore:InvokeAgentRuntimeForUser', 'bedrock-agentcore:StopRuntimeSession', ], resources: runtimeResources, diff --git a/cdk/src/handlers/orchestrate-task.ts b/cdk/src/handlers/orchestrate-task.ts index fe244559..8cb6c6da 100644 --- a/cdk/src/handlers/orchestrate-task.ts +++ b/cdk/src/handlers/orchestrate-task.ts @@ -137,7 +137,12 @@ const durableHandler: DurableExecutionHandler = asyn const sessionHandle = await context.step('start-session', async () => { try { const strategy = resolveComputeStrategy(blueprintConfig); - const handle = await strategy.startSession({ taskId, payload, blueprintConfig }); + const handle = await strategy.startSession({ + taskId, + userId: task.user_id, + payload, + blueprintConfig, + }); // Build compute metadata for the task record so cancel-task can stop the right backend const computeMetadata: Record = handle.strategyType === 'ecs' diff --git a/cdk/src/handlers/shared/compute-strategy.ts b/cdk/src/handlers/shared/compute-strategy.ts index e3d3c1d1..c3de5886 100644 --- a/cdk/src/handlers/shared/compute-strategy.ts +++ b/cdk/src/handlers/shared/compute-strategy.ts @@ -34,6 +34,18 @@ export interface ComputeStrategy { readonly type: ComputeType; startSession(input: { taskId: string; + /** + * Stable user identifier (the task's Cognito sub) propagated to + * AgentCore via `runtimeUserId` on `InvokeAgentRuntimeCommand`. Used + * by AgentCore Identity to derive a workload access token and inject + * it into the agent container via the `WorkloadAccessToken` request + * header. Without this, `BedrockAgentCoreContext.get_workload_ + * access_token()` returns None inside the runtime and any code path + * that resolves a credential through Identity (e.g. + * `agent/src/config.py::resolve_linear_api_token`) silently + * fails-closed. Phase 2.0a requirement. + */ + userId: string; payload: Record; blueprintConfig: BlueprintConfig; }): Promise; diff --git a/cdk/src/handlers/shared/strategies/agentcore-strategy.ts b/cdk/src/handlers/shared/strategies/agentcore-strategy.ts index 27604adb..d10e9bde 100644 --- a/cdk/src/handlers/shared/strategies/agentcore-strategy.ts +++ b/cdk/src/handlers/shared/strategies/agentcore-strategy.ts @@ -36,6 +36,7 @@ export class AgentCoreComputeStrategy implements ComputeStrategy { async startSession(input: { taskId: string; + userId: string; payload: Record; blueprintConfig: BlueprintConfig; }): Promise { @@ -43,9 +44,19 @@ export class AgentCoreComputeStrategy implements ComputeStrategy { const sessionId = randomUUID(); const runtimeArn = input.blueprintConfig.runtime_arn; + // `runtimeUserId` triggers AgentCore Identity's workload-access-token + // injection: when set, AgentCore exchanges the caller's identity for + // a workload token and delivers it to the agent container via the + // `WorkloadAccessToken` request header (read by + // `BedrockAgentCoreContext.set_workload_access_token` in app.py). + // Without it, the agent's `resolve_linear_api_token()` short-circuits + // before reaching the Identity SDK call. Requires the orchestrator + // role to have `bedrock-agentcore:InvokeAgentRuntimeForUser` in + // addition to `InvokeAgentRuntime`. const command = new InvokeAgentRuntimeCommand({ agentRuntimeArn: runtimeArn, runtimeSessionId: sessionId, + runtimeUserId: input.userId, contentType: 'application/json', accept: 'application/json', payload: new TextEncoder().encode(JSON.stringify({ input: input.payload })), @@ -53,7 +64,12 @@ export class AgentCoreComputeStrategy implements ComputeStrategy { await getClient().send(command); - logger.info('AgentCore session invoked', { task_id: input.taskId, session_id: sessionId, runtime_arn: runtimeArn }); + logger.info('AgentCore session invoked', { + task_id: input.taskId, + session_id: sessionId, + runtime_arn: runtimeArn, + runtime_user_id: input.userId, + }); return { sessionId, diff --git a/cdk/src/handlers/shared/strategies/ecs-strategy.ts b/cdk/src/handlers/shared/strategies/ecs-strategy.ts index 5c0ad674..8a6270c4 100644 --- a/cdk/src/handlers/shared/strategies/ecs-strategy.ts +++ b/cdk/src/handlers/shared/strategies/ecs-strategy.ts @@ -41,6 +41,9 @@ export class EcsComputeStrategy implements ComputeStrategy { async startSession(input: { taskId: string; + /** Accepted to satisfy the ComputeStrategy interface; ECS doesn't + * use a workload-token-injecting runtime so this is unused. */ + userId: string; payload: Record; blueprintConfig: BlueprintConfig; }): Promise { diff --git a/cdk/test/constructs/task-orchestrator.test.ts b/cdk/test/constructs/task-orchestrator.test.ts index 6cf903a1..d3e56df4 100644 --- a/cdk/test/constructs/task-orchestrator.test.ts +++ b/cdk/test/constructs/task-orchestrator.test.ts @@ -148,6 +148,7 @@ describe('TaskOrchestrator construct', () => { Match.objectLike({ Action: [ 'bedrock-agentcore:InvokeAgentRuntime', + 'bedrock-agentcore:InvokeAgentRuntimeForUser', 'bedrock-agentcore:StopRuntimeSession', ], Effect: 'Allow', @@ -295,6 +296,7 @@ describe('TaskOrchestrator construct', () => { Match.objectLike({ Action: [ 'bedrock-agentcore:InvokeAgentRuntime', + 'bedrock-agentcore:InvokeAgentRuntimeForUser', 'bedrock-agentcore:StopRuntimeSession', ], Effect: 'Allow', diff --git a/cdk/test/handlers/shared/strategies/agentcore-strategy.test.ts b/cdk/test/handlers/shared/strategies/agentcore-strategy.test.ts index 46f3f7e7..66a66884 100644 --- a/cdk/test/handlers/shared/strategies/agentcore-strategy.test.ts +++ b/cdk/test/handlers/shared/strategies/agentcore-strategy.test.ts @@ -47,6 +47,7 @@ describe('AgentCoreComputeStrategy', () => { const handle = await strategy.startSession({ taskId: 'TASK001', + userId: 'cognito-user-1', payload: { repo_url: 'org/repo', task_id: 'TASK001' }, blueprintConfig: { compute_type: 'agentcore', runtime_arn: defaultRuntimeArn }, }); @@ -56,6 +57,9 @@ describe('AgentCoreComputeStrategy', () => { const acHandle = handle as Extract; expect(acHandle.runtimeArn).toBe(defaultRuntimeArn); expect(mockSend).toHaveBeenCalledTimes(1); + // runtimeUserId triggers AgentCore Identity workload-token injection. + const invokeInput = mockSend.mock.calls[0][0].input; + expect(invokeInput.runtimeUserId).toBe('cognito-user-1'); }); test('uses runtime_arn from blueprintConfig (single source of truth)', async () => { @@ -65,6 +69,7 @@ describe('AgentCoreComputeStrategy', () => { const handle = await strategy.startSession({ taskId: 'TASK001', + userId: 'cognito-user-1', payload: { repo_url: 'org/repo', task_id: 'TASK001' }, blueprintConfig: { compute_type: 'agentcore', runtime_arn: runtimeArn }, }); @@ -86,11 +91,13 @@ describe('AgentCoreComputeStrategy', () => { await strategy1.startSession({ taskId: 'T1', + userId: 'u1', payload: {}, blueprintConfig: { compute_type: 'agentcore', runtime_arn: defaultRuntimeArn }, }); await strategy2.startSession({ taskId: 'T2', + userId: 'u2', payload: {}, blueprintConfig: { compute_type: 'agentcore', runtime_arn: defaultRuntimeArn }, }); diff --git a/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts b/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts index 0e365a17..44748370 100644 --- a/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts +++ b/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts @@ -57,6 +57,7 @@ describe('EcsComputeStrategy', () => { const strategy = new EcsComputeStrategy(); const handle = await strategy.startSession({ taskId: 'TASK001', + userId: 'cognito-test', payload: { repo_url: 'org/repo', prompt: 'Fix the bug', issue_number: 42, max_turns: 50 }, blueprintConfig: { compute_type: 'ecs', runtime_arn: '' }, }); @@ -109,6 +110,7 @@ describe('EcsComputeStrategy', () => { await expect( strategy.startSession({ taskId: 'TASK001', + userId: 'cognito-test', payload: { repo_url: 'org/repo' }, blueprintConfig: { compute_type: 'ecs', runtime_arn: '' }, }), @@ -123,6 +125,7 @@ describe('EcsComputeStrategy', () => { const strategy = new EcsComputeStrategy(); await strategy.startSession({ taskId: 'TASK001', + userId: 'cognito-test', payload: { repo_url: 'org/repo' }, blueprintConfig: { compute_type: 'ecs', diff --git a/cdk/test/handlers/start-session-composition.test.ts b/cdk/test/handlers/start-session-composition.test.ts index e9592d66..1fe29fbd 100644 --- a/cdk/test/handlers/start-session-composition.test.ts +++ b/cdk/test/handlers/start-session-composition.test.ts @@ -91,7 +91,7 @@ describe('start-session step composition', () => { mockDdbSend.mockResolvedValue({}); // transitionTask + emitTaskEvent const strategy = resolveComputeStrategy(blueprintConfig); - const handle = await strategy.startSession({ taskId, payload, blueprintConfig }); + const handle = await strategy.startSession({ taskId, userId: 'cognito-test', payload, blueprintConfig }); await transitionTask(taskId, TaskStatus.HYDRATING, TaskStatus.RUNNING, { session_id: handle.sessionId, @@ -118,7 +118,7 @@ describe('start-session step composition', () => { const strategy = resolveComputeStrategy(blueprintConfig); try { - await strategy.startSession({ taskId, payload, blueprintConfig }); + await strategy.startSession({ taskId, userId: 'cognito-test', payload, blueprintConfig }); fail('Expected startSession to throw'); } catch (err) { await failTask(taskId, TaskStatus.HYDRATING, `Session start failed: ${String(err)}`, userId, true); @@ -138,7 +138,7 @@ describe('start-session step composition', () => { .mockResolvedValue({}); // failTask calls const strategy = resolveComputeStrategy(blueprintConfig); - const handle = await strategy.startSession({ taskId, payload, blueprintConfig }); + const handle = await strategy.startSession({ taskId, userId: 'cognito-test', payload, blueprintConfig }); try { await transitionTask(taskId, TaskStatus.HYDRATING, TaskStatus.RUNNING, { From c5a12b0cc7365b65529eba9432d0ea3a1e7a2298 Mon Sep 17 00:00:00 2001 From: bgagent Date: Mon, 18 May 2026 13:24:26 -0700 Subject: [PATCH 4/4] fix(linear): two undocumented gotchas to make AgentCore Identity actually work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end smoke on backgroundagent-dev surfaced two more silent failure modes after the runtimeUserId fix landed: ## 1) Per-thread ContextVar propagation (agent/src/server.py) `BedrockAgentCoreContext.get_workload_access_token()` returned None inside the pipeline thread even though the platform delivered the token on the inbound request. Cause: Python ContextVar storage is per-thread, not shared across `threading.Thread` boundaries. Our `_run_task_background` spawns a new thread for the pipeline, so any context-var the SDK's middleware sets in the request handler thread doesn't reach it. Compounding factor: the SDK's `_build_request_context` middleware only runs when using `BedrockAgentCoreApp` from `bedrock_agentcore.runtime`. Plain FastAPI apps like ours never get that bridge at all. Fix: read the workload token off the request in `_extract_invocation_params` (handling both observed header spellings — `WorkloadAccessToken` and `x-amzn-bedrock-agentcore-runtime-workload-accesstoken`), thread it through the kwargs of `_run_task_background`, and have the pipeline thread call `BedrockAgentCoreContext.set_workload_access_token` on entry. ## 2) Runtime role needs GetSecretValue on the Identity vault prefix (cdk/src/stacks/agent.ts) After (1) was applied, `IdentityClient.get_api_key()` actually fired and got `AccessDeniedException: ... not authorized to perform: secretsmanager:GetSecretValue`. Cause: AgentCore Identity stores api-key credentials in Secrets Manager under reserved prefix `bedrock-agentcore-identity!*` (the actual ARN shape: `arn:aws:secretsmanager:REGION:ACCOUNT:secret:bedrock-agentcore- identity!default/apikey/-`). The `GetResourceApiKey` control-plane API surfaces the underlying secret to the caller, and AWS verifies the *caller* role (our runtime role) has `GetSecretValue` on the actual secret resource — not the SLR. Fix: grant the runtime role `secretsmanager:GetSecretValue` scoped to the `bedrock-agentcore-identity!*` prefix in the current account/region. Tightly scoped to Identity-managed secrets; doesn't leak read access to other Secrets Manager resources. ## Verified working end-to-end (backgroundagent-dev, 2026-05-18) - Runtime container reads workload token from request, propagates across thread boundary, calls IdentityClient successfully - 👀 reaction posts at +525ms after task pickup, no warnings - Linear MCP loads cleanly with the resolved token - No more `workload access token not in context` WARN - No more `AccessDeniedException` from `GetResourceApiKey` Three undocumented requirements total for Phase 2.0a (combining with the runtimeUserId fix from the prior commit): 1. Caller (orchestrator) sends `runtimeUserId` and has `InvokeAgentRuntimeForUser` IAM 2. Runtime container bridges the workload-token header into the ContextVar, with per-thread propagation if the pipeline runs in a spawned thread 3. Runtime role has `secretsmanager:GetSecretValue` on `bedrock-agentcore-identity!*` All three are silent failures on their own; missing any one returns None or AccessDenied without obvious "you forgot X" diagnostics. Will file an upstream issue against `aws/bedrock-agentcore-sdk-python` summarising all three so others don't burn the same cycles. Tests: 542 agent / 1273 cdk / 196 cli — all green. Co-Authored-By: Claude Opus 4.7 (1M context) --- agent/src/server.py | 54 +++++++++++++++++++++++++++++++++++++++++ cdk/src/stacks/agent.ts | 14 +++++++++++ 2 files changed, 68 insertions(+) diff --git a/agent/src/server.py b/agent/src/server.py index aa547a2d..efcd571e 100644 --- a/agent/src/server.py +++ b/agent/src/server.py @@ -209,6 +209,38 @@ async def lifespan(_application: FastAPI): app = FastAPI(title="Background Agent", version="1.0.0", lifespan=lifespan) +def _extract_workload_access_token(request: Request) -> str: + """Read AgentCore's workload access token off the inbound request. + + AgentCore Runtime delivers the token on `/invocations` requests under + one of two header spellings (both observed 2026-05-18 on a single + request via diagnostic logging in us-east-1): + 1. ``WorkloadAccessToken`` — the SDK's documented header in + ``bedrock_agentcore.runtime.models::ACCESS_TOKEN_HEADER``. + 2. ``x-amzn-bedrock-agentcore-runtime-workload-accesstoken`` — + undocumented but present on the wire; included for forward + compatibility. + + The token must be propagated explicitly into the pipeline thread (see + ``_run_task_background``) because Python ``ContextVar`` is per-thread, + not per-request — the SDK's bundled ``_build_request_context`` + middleware sets it in the request handler's async context, but our + pipeline runs in a separate ``threading.Thread`` spawned by + ``_spawn_background``. The new thread sees a fresh empty ContextVar + unless we re-set it on entry. + + See aws/bedrock-agentcore-sdk-python#219 for the upstream tracking + issue (per-thread ContextVar) and the workaround pattern in + ``awslabs/agentcore-samples`` 07-Outbound_Auth_3LO_ECS_Fargate. + """ + return ( + request.headers.get("WorkloadAccessToken") + or request.headers.get("x-amzn-bedrock-agentcore-runtime-workload-accesstoken") + or request.headers.get("x-amzn-bedrock-agentcore-workload-access-token") + or "" + ) + + class InvocationRequest(BaseModel): input: dict[str, Any] @@ -277,10 +309,24 @@ def _run_task_background( channel_metadata: dict[str, str] | None = None, trace: bool = False, user_id: str = "", + workload_access_token: str = "", ) -> None: """Run the agent task in a background thread.""" global _background_pipeline_failed + # Re-establish the AgentCore workload-token ContextVar in this thread. + # Python ContextVar storage is per-thread, so the request-handler thread's + # context (where BedrockAgentCoreApp's _build_request_context would normally + # set this) doesn't propagate to here. Without this re-set, + # IdentityClient.get_api_key() callers like resolve_linear_api_token() + # short-circuit on a None workload token even when the platform delivered + # one. See aws/bedrock-agentcore-sdk-python#219 for the upstream design + # constraint that motivates this manual propagation. + if workload_access_token: + from bedrock_agentcore.runtime.context import BedrockAgentCoreContext + + BedrockAgentCoreContext.set_workload_access_token(workload_access_token) + _debug_cw( f"_run_task_background ENTERED task_id={task_id!r} " f"thread={threading.current_thread().name!r}", @@ -399,6 +445,13 @@ def _extract_invocation_params(inp: dict, request: Request) -> dict: session_id = request.headers.get("x-amzn-bedrock-agentcore-runtime-session-id", "") + # AgentCore-injected workload access token (see _extract_workload_access_token + # for full rationale). Threaded into _run_task_background so the pipeline + # thread can call BedrockAgentCoreContext.set_workload_access_token() on entry + # — without that the IdentityClient.get_api_key path used by + # resolve_linear_api_token() returns None. + workload_access_token = _extract_workload_access_token(request) + return { "repo_url": repo_url, "task_description": task_description, @@ -422,6 +475,7 @@ def _extract_invocation_params(inp: dict, request: Request) -> dict: "channel_metadata": channel_metadata, "trace": trace, "user_id": user_id, + "workload_access_token": workload_access_token, } diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index a43db78f..171cf37b 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -621,6 +621,20 @@ export class AgentStack extends Stack { // 2.0b once OAuth migration documents the canonical resource shape. resources: ['*'], })); + // AgentCore Identity stores credential-vault payloads in Secrets Manager + // under the reserved prefix `bedrock-agentcore-identity!*`. The + // `GetResourceApiKey` API call surfaces the underlying secret value to + // the caller, and AWS verifies the caller (our runtime role) has + // GetSecretValue on the actual secret resource — empirically confirmed + // 2026-05-18 against an api-key provider in us-east-1. This grant is + // tightly scoped to the Identity-managed prefix; it does NOT grant read + // access to any other Secrets Manager resources in the account. + runtime.role.addToPrincipalPolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:GetSecretValue'], + resources: [ + `arn:aws:secretsmanager:${this.region}:${this.account}:secret:bedrock-agentcore-identity!*`, + ], + })); // Pipe the Linear API token secret into the orchestrator Lambda so the // concurrency-cap rejection path can post a Linear comment + ❌ instead